Merge remote-tracking branch 'upstream/develop' into develop

This commit is contained in:
marcelo 2022-06-17 21:15:42 -07:00
commit ebbb3ec86b
202 changed files with 11600 additions and 2332 deletions

View file

@ -16,7 +16,7 @@ namespace API.Benchmark;
public class EpubBenchmark
{
[Benchmark]
public async Task GetWordCount_PassByString()
public static async Task GetWordCount_PassByString()
{
using var book = await EpubReader.OpenBookAsync("Data/book-test.epub", BookService.BookReaderOptions);
foreach (var bookFile in book.Content.Html.Values)
@ -27,7 +27,7 @@ public class EpubBenchmark
}
[Benchmark]
public async Task GetWordCount_PassByRef()
public static async Task GetWordCount_PassByRef()
{
using var book = await EpubReader.OpenBookAsync("Data/book-test.epub", BookService.BookReaderOptions);
foreach (var bookFile in book.Content.Html.Values)

View file

@ -69,6 +69,10 @@ namespace API.Tests.Parser
[InlineData("幽游白书完全版 第03卷 天下", "3")]
[InlineData("阿衰online 第1册", "1")]
[InlineData("【TFO汉化&Petit汉化】迷你偶像漫画卷2第25话", "2")]
[InlineData("63권#200", "63")]
[InlineData("시즌34삽화2", "34")]
[InlineData("スライム倒して300年、知らないうちにレベルMAXになってました 1巻", "1")]
[InlineData("スライム倒して300年、知らないうちにレベルMAXになってました 1-3巻", "1-3")]
public void ParseVolumeTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseVolume(filename));
@ -250,7 +254,8 @@ namespace API.Tests.Parser
[InlineData("Kaiju No. 8 036 (2021) (Digital)", "36")]
[InlineData("Samurai Jack Vol. 01 - The threads of Time", "0")]
[InlineData("【TFO汉化&Petit汉化】迷你偶像漫画第25话", "25")]
[InlineData("【TFO汉化&Petit汉化】迷你偶像漫画卷2第25话", "25")]
[InlineData("이세계에서 고아원을 열었지만, 어째서인지 아무도 독립하려 하지 않는다 38-1화 ", "38")]
[InlineData("[ハレム] SMごっこ 10", "10")]
public void ParseChaptersTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseChapter(filename));

View file

@ -279,8 +279,8 @@ namespace API.Tests.Services
}
}
};
cs.GetCachedEpubFile(1, c);
Assert.Same($"{DataDirectory}1.epub", cs.GetCachedEpubFile(1, c));
cs.GetCachedFile(c);
Assert.Same($"{DataDirectory}1.epub", cs.GetCachedFile(c));
}
#endregion

View file

@ -8,6 +8,7 @@ using API.Data.Repositories;
using API.DTOs;
using API.DTOs.CollectionTags;
using API.DTOs.Metadata;
using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
@ -21,6 +22,8 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.Extensions;
using NSubstitute.ReceivedExtensions;
using Xunit;
using Xunit.Sdk;
@ -253,6 +256,50 @@ public class SeriesServiceTests
Assert.Equal(2, detail.Volumes.Count());
}
[Fact]
public async Task SeriesDetail_ShouldReturnCorrectNaming_VolumeTitle()
{
await ResetDb();
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>()),
EntityFactory.CreateChapter("2", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>()),
}),
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("31", false, new List<MangaFile>()),
}),
}
});
await _context.SaveChangesAsync();
var detail = await _seriesService.GetSeriesDetail(1, 1);
Assert.NotEmpty(detail.Chapters);
// volume 2 has a 0 chapter aka a single chapter that is represented as a volume. We don't show in Chapters area
Assert.Equal(3, detail.Chapters.Count());
Assert.NotEmpty(detail.Volumes);
Assert.Equal(2, detail.Volumes.Count());
Assert.Equal(string.Empty, detail.Chapters.First().VolumeTitle); // loose leaf chapter
Assert.Equal("Volume 3", detail.Chapters.Last().VolumeTitle); // volume based chapter
}
[Fact]
public async Task SeriesDetail_ShouldReturnChaptersOnly_WhenBookLibrary()
{
@ -700,7 +747,7 @@ public class SeriesServiceTests
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
Assert.NotNull(series.Metadata);
Assert.True(series.Metadata.Genres.Select(g => g.Title).All(g => g == "New Genre".SentenceCase()));
Assert.True(series.Metadata.Genres.Select(g1 => g1.Title).All(g2 => g2 == "New Genre".SentenceCase()));
Assert.False(series.Metadata.GenresLocked); // GenreLocked is false unless the UI Explicitly says it should be locked
}

View file

@ -7,6 +7,7 @@ using API.Data;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.Theme;
using API.Entities.Enums.UserPreferences;
using API.Helpers;
using API.Services;
using API.Services.Tasks;

View file

@ -46,7 +46,7 @@
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.29" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.MemoryStorage.Core" Version="1.4.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.42" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.43" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="6.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.5" />
@ -63,14 +63,14 @@
<PackageReference Include="NetVips.Native" Version="8.12.2" />
<PackageReference Include="NReco.Logging.File" Version="1.1.5" />
<PackageReference Include="SharpCompress" Version="0.31.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.1" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.39.0.47922">
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.2" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.40.0.48530">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.3.1" />
<PackageReference Include="System.Drawing.Common" Version="6.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.18.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.19.0" />
<PackageReference Include="System.IO.Abstractions" Version="17.0.15" />
<PackageReference Include="VersOne.Epub" Version="3.1.1" />
</ItemGroup>

View file

@ -12,6 +12,7 @@ using API.DTOs.Account;
using API.DTOs.Email;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.UserPreferences;
using API.Errors;
using API.Extensions;
using API.Services;
@ -652,22 +653,13 @@ namespace API.Controllers
try
{
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
//if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue sending email");
user.Email = dto.Email;
if (!await ConfirmEmailToken(token, user)) return BadRequest("There was a critical error during migration");
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
//var emailLink = GenerateEmailLink(await _userManager.GenerateEmailConfirmationTokenAsync(user), "confirm-migration-email", user.Email);
// _logger.LogCritical("[Email Migration]: Email Link for {UserName}: {Link}", dto.Username, emailLink);
// // Always send an email, even if the user can't click it just to get them conformable with the system
// await _emailService.SendMigrationEmail(new EmailMigrationDto()
// {
// EmailAddress = dto.Email,
// Username = user.UserName,
// ServerConfirmationLink = emailLink
// });
return Ok();
}
catch (Exception ex)

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
@ -37,11 +38,34 @@ namespace API.Controllers
{
var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId);
var bookTitle = string.Empty;
if (dto.SeriesFormat == MangaFormat.Epub)
switch (dto.SeriesFormat)
{
case MangaFormat.Epub:
{
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First();
using var book = await EpubReader.OpenBookAsync(mangaFile.FilePath, BookService.BookReaderOptions);
bookTitle = book.Title;
break;
}
case MangaFormat.Pdf:
{
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First();
if (string.IsNullOrEmpty(bookTitle))
{
// Override with filename
bookTitle = Path.GetFileNameWithoutExtension(mangaFile.FilePath);
}
break;
}
case MangaFormat.Image:
break;
case MangaFormat.Archive:
break;
case MangaFormat.Unknown:
break;
default:
throw new ArgumentOutOfRangeException();
}
return Ok(new BookInfoDto()
@ -209,7 +233,7 @@ namespace API.Controllers
public async Task<ActionResult<string>> GetBookPage(int chapterId, [FromQuery] int page)
{
var chapter = await _cacheService.Ensure(chapterId);
var path = _cacheService.GetCachedEpubFile(chapter.Id, chapter);
var path = _cacheService.GetCachedFile(chapter);
using var book = await EpubReader.OpenBookAsync(path, BookService.BookReaderOptions);
var mappings = await _bookService.CreateKeyToPageMappingAsync(book);

View file

@ -6,7 +6,9 @@ using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.JumpBar;
using API.DTOs.Search;
using API.DTOs.System;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
@ -88,11 +90,15 @@ namespace API.Controllers
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("list")]
public ActionResult<IEnumerable<string>> GetDirectories(string path)
public ActionResult<IEnumerable<DirectoryDto>> GetDirectories(string path)
{
if (string.IsNullOrEmpty(path))
{
return Ok(Directory.GetLogicalDrives());
return Ok(Directory.GetLogicalDrives().Select(d => new DirectoryDto()
{
Name = d,
FullPath = d
}));
}
if (!Directory.Exists(path)) return BadRequest("This is not a valid path");
@ -106,6 +112,16 @@ namespace API.Controllers
return Ok(await _unitOfWork.LibraryRepository.GetLibraryDtosAsync());
}
[HttpGet("jump-bar")]
public async Task<ActionResult<IEnumerable<JumpKeyDto>>> GetJumpBar(int libraryId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
if (!await _unitOfWork.UserRepository.HasAccessToLibrary(libraryId, userId)) return BadRequest("User does not have access to library");
return Ok(_unitOfWork.LibraryRepository.GetJumpBarAsync(libraryId));
}
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("grant-access")]
public async Task<ActionResult<MemberDto>> UpdateUserLibraries(UpdateLibraryForUserDto updateLibraryForUserDto)
@ -170,7 +186,7 @@ namespace API.Controllers
[HttpPost("analyze")]
public ActionResult Analyze(int libraryId)
{
_taskScheduler.AnalyzeFilesForLibrary(libraryId);
_taskScheduler.AnalyzeFilesForLibrary(libraryId, true);
return Ok();
}

View file

@ -83,7 +83,7 @@ public class MetadataController : BaseApiController
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
if (ids != null && ids.Count > 0)
{
return Ok(await _unitOfWork.SeriesRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
return Ok(await _unitOfWork.LibraryRepository.GetAllAgeRatingsDtosForLibrariesAsync(ids));
}
return Ok(Enum.GetValues<AgeRating>().Select(t => new AgeRatingDto()
@ -104,7 +104,7 @@ public class MetadataController : BaseApiController
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
if (ids is {Count: > 0})
{
return Ok(_unitOfWork.SeriesRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids));
return Ok(_unitOfWork.LibraryRepository.GetAllPublicationStatusesDtosForLibrariesAsync(ids));
}
return Ok(Enum.GetValues<PublicationStatus>().Select(t => new PublicationStatusDto()
@ -125,7 +125,7 @@ public class MetadataController : BaseApiController
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
if (ids is {Count: > 0})
{
return Ok(await _unitOfWork.SeriesRepository.GetAllLanguagesForLibrariesAsync(ids));
return Ok(await _unitOfWork.LibraryRepository.GetAllLanguagesForLibrariesAsync(ids));
}
var englishTag = CultureInfo.GetCultureInfo("en");
@ -149,4 +149,18 @@ public class MetadataController : BaseApiController
IsoCode = c.IetfLanguageTag
}).Where(l => !string.IsNullOrEmpty(l.IsoCode));
}
/// <summary>
/// Returns summary for the chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("chapter-summary")]
public async Task<ActionResult<string>> GetChapterSummary(int chapterId)
{
if (chapterId <= 0) return BadRequest("Chapter does not exist");
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
if (chapter == null) return BadRequest("Chapter does not exist");
return Ok(chapter.Summary);
}
}

View file

@ -604,6 +604,7 @@ public class OpdsController : BaseApiController
/// <summary>
/// Downloads a file
/// </summary>
/// <param name="apiKey">User's API Key</param>
/// <param name="seriesId"></param>
/// <param name="volumeId"></param>
/// <param name="chapterId"></param>

View file

@ -8,9 +8,9 @@ using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
using API.Services.Tasks;
using API.SignalR;
using Hangfire;
using Microsoft.AspNetCore.Mvc;
@ -44,6 +44,34 @@ namespace API.Controllers
_eventHub = eventHub;
}
/// <summary>
/// Returns the PDF for the chapterId.
/// </summary>
/// <param name="apiKey">API Key for user to validate they have access</param>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("pdf")]
public async Task<ActionResult> GetPdf(int chapterId)
{
var chapter = await _cacheService.Ensure(chapterId);
if (chapter == null) return BadRequest("There was an issue finding pdf file for reading");
try
{
var path = _cacheService.GetCachedFile(chapter);
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"Pdf doesn't exist when it should.");
Response.AddCacheHeader(path, TimeSpan.FromMinutes(60).Seconds);
return PhysicalFile(path, "application/pdf", Path.GetFileName(path), true);
}
catch (Exception)
{
_cacheService.CleanupChapters(new []{ chapterId });
throw;
}
}
/// <summary>
/// Returns an image for a given chapter. Side effect: This will cache the chapter images for reading.
/// </summary>
@ -627,5 +655,34 @@ namespace API.Controllers
return await _readerService.GetPrevChapterIdAsync(seriesId, volumeId, currentChapterId, userId);
}
/// <summary>
/// For the current user, returns an estimate on how long it would take to finish reading the series.
/// </summary>
/// <remarks>For Epubs, this does not check words inside a chapter due to overhead so may not work in all cases.</remarks>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("time-left")]
public async Task<ActionResult<HourEstimateRangeDto>> GetEstimateToCompletion(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
// Get all sum of all chapters with progress that is complete then subtract from series. Multiply by modifiers
var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(seriesId, userId);
if (series.Format == MangaFormat.Epub)
{
var chapters =
await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(progress.Select(p => p.ChapterId).ToList());
// Word count
var progressCount = chapters.Sum(c => c.WordCount);
var wordsLeft = series.WordCount - progressCount;
return _readerService.GetTimeEstimate(wordsLeft, 0, true);
}
var progressPageCount = progress.Sum(p => p.PagesRead);
var pagesLeft = series.Pages - progressPageCount;
return _readerService.GetTimeEstimate(0, pagesLeft, false);
}
}
}

View file

@ -19,9 +19,10 @@ public class RecommendedController : BaseApiController
/// <summary>
/// Quick Reads are series that are less than 2K pages in total.
/// Quick Reads are series that should be readable in less than 10 in total and are not Ongoing in release.
/// </summary>
/// <param name="libraryId">Library to restrict series to</param>
/// <param name="userParams">Pagination</param>
/// <returns></returns>
[HttpGet("quick-reads")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetQuickReads(int libraryId, [FromQuery] UserParams userParams)
@ -35,10 +36,29 @@ public class RecommendedController : BaseApiController
return Ok(series);
}
/// <summary>
/// Quick Catchup Reads are series that should be readable in less than 10 in total and are Ongoing in release.
/// </summary>
/// <param name="libraryId">Library to restrict series to</param>
/// <param name="userParams"></param>
/// <returns></returns>
[HttpGet("quick-catchup-reads")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetQuickCatchupReads(int libraryId, [FromQuery] UserParams userParams)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
userParams ??= new UserParams();
var series = await _unitOfWork.SeriesRepository.GetQuickCatchupReads(user.Id, libraryId, userParams);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
}
/// <summary>
/// Highly Rated based on other users ratings. Will pull series with ratings > 4.0, weighted by count of other users.
/// </summary>
/// <param name="libraryId">Library to restrict series to</param>
/// <param name="userParams">Pagination</param>
/// <returns></returns>
[HttpGet("highly-rated")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetHighlyRated(int libraryId, [FromQuery] UserParams userParams)
@ -56,6 +76,8 @@ public class RecommendedController : BaseApiController
/// Chooses a random genre and shows series that are in that without reading progress
/// </summary>
/// <param name="libraryId">Library to restrict series to</param>
/// <param name="genreId">Genre Id</param>
/// <param name="userParams">Pagination</param>
/// <returns></returns>
[HttpGet("more-in")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetMoreIn(int libraryId, int genreId, [FromQuery] UserParams userParams)
@ -74,6 +96,7 @@ public class RecommendedController : BaseApiController
/// Series that are fully read by the user in no particular order
/// </summary>
/// <param name="libraryId">Library to restrict series to</param>
/// <param name="userParams">Pagination</param>
/// <returns></returns>
[HttpGet("rediscover")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetRediscover(int libraryId, [FromQuery] UserParams userParams)

View file

@ -394,6 +394,8 @@ namespace API.Controllers
return Ok(await _unitOfWork.SeriesRepository.GetRelatedSeries(userId, seriesId));
}
[Authorize(Policy="RequireAdminRole")]
[HttpPost("update-related")]
public async Task<ActionResult> UpdateRelatedSeries(UpdateRelatedSeriesDto dto)

View file

@ -206,6 +206,12 @@ namespace API.Controllers
}
}
if (setting.Key == ServerSettingKey.EnableSwaggerUi && updateSettingsDto.EnableSwaggerUi + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.EnableSwaggerUi + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.EmailServiceUrl && updateSettingsDto.EmailServiceUrl + string.Empty != setting.Value)
{
setting.Value = string.IsNullOrEmpty(updateSettingsDto.EmailServiceUrl) ? EmailService.DefaultApiUrl : updateSettingsDto.EmailServiceUrl;

View file

@ -92,8 +92,9 @@ namespace API.Controllers
existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection;
preferencesDto.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
existingPreferences.BookThemeName = preferencesDto.BookReaderThemeName;
existingPreferences.PageLayoutMode = preferencesDto.BookReaderLayoutMode;
existingPreferences.BookReaderLayoutMode = preferencesDto.BookReaderLayoutMode;
existingPreferences.BookReaderImmersiveMode = preferencesDto.BookReaderImmersiveMode;
existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode;
existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id);
// TODO: Remove this code - this overrides layout mode to be single until the mode is released

View file

@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
using API.DTOs.Metadata;
using API.DTOs.Reader;
using API.Entities.Enums;
using API.Entities.Interfaces;
namespace API.DTOs
{
@ -8,7 +11,7 @@ namespace API.DTOs
/// A Chapter is the lowest grouping of a reading medium. A Chapter contains a set of MangaFiles which represents the underlying
/// file (abstracted from type).
/// </summary>
public class ChapterDto
public class ChapterDto : IHasReadTimeEstimate
{
public int Id { get; init; }
/// <summary>
@ -62,8 +65,29 @@ namespace API.DTOs
/// <remarks>Metadata field</remarks>
public string TitleName { get; set; }
/// <summary>
/// Number of Words for this chapter. Only applies to Epub
/// Summary of the Chapter
/// </summary>
public long WordCount { get; set; }
/// <remarks>This is not set normally, only for Series Detail</remarks>
public string Summary { get; init; }
/// <summary>
/// Age Rating for the issue/chapter
/// </summary>
public AgeRating AgeRating { get; init; }
/// <summary>
/// Total words in a Chapter (books only)
/// </summary>
public long WordCount { get; set; } = 0L;
/// <summary>
/// Formatted Volume title ie) Volume 2.
/// </summary>
/// <remarks>Only available when fetched from Series Detail API</remarks>
public string VolumeTitle { get; set; } = string.Empty;
/// <inheritdoc cref="IHasReadTimeEstimate.MinHoursToRead"/>
public int MinHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
public int AvgHoursToRead { get; set; }
}
}

View file

@ -0,0 +1,20 @@
namespace API.DTOs.JumpBar;
/// <summary>
/// Represents an individual button in a Jump Bar
/// </summary>
public class JumpKeyDto
{
/// <summary>
/// Number of items in this Key
/// </summary>
public int Size { get; set; }
/// <summary>
/// Code to use in URL (url encoded)
/// </summary>
public string Key { get; set; }
/// <summary>
/// What is visible to user
/// </summary>
public string Title { get; set; }
}

View file

@ -47,6 +47,10 @@ namespace API.DTOs.Metadata
/// Total number of issues for the series
/// </summary>
public int TotalCount { get; set; }
/// <summary>
/// Number of Words for this chapter. Only applies to Epub
/// </summary>
public long WordCount { get; set; }
}
}

View file

@ -0,0 +1,20 @@
namespace API.DTOs.Reader;
/// <summary>
/// A range of time to read a selection (series, chapter, etc)
/// </summary>
public record HourEstimateRangeDto
{
/// <summary>
/// Min hours to read the selection
/// </summary>
public int MinHours { get; init; } = 1;
/// <summary>
/// Max hours to read the selection
/// </summary>
public int MaxHours { get; init; } = 1;
/// <summary>
/// Estimated average hours to read the selection
/// </summary>
public int AvgHours { get; init; } = 1;
}

View file

@ -1,6 +1,6 @@
using System.Collections.Generic;
namespace API.DTOs;
namespace API.DTOs.SeriesDetail;
/// <summary>
/// This is a special DTO for a UI page in Kavita. This performs sorting and grouping and returns exactly what UI requires for layout.

View file

@ -1,9 +1,10 @@
using System;
using API.Entities.Enums;
using API.Entities.Interfaces;
namespace API.DTOs
{
public class SeriesDto
public class SeriesDto : IHasReadTimeEstimate
{
public int Id { get; init; }
public string Name { get; init; }
@ -47,5 +48,11 @@ namespace API.DTOs
public int LibraryId { get; set; }
public string LibraryName { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.MinHoursToRead"/>
public int MinHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
public int AvgHoursToRead { get; set; }
}
}

View file

@ -40,5 +40,9 @@ namespace API.DTOs.Settings
public string InstallVersion { get; set; }
public bool ConvertBookmarkToWebP { get; set; }
/// <summary>
/// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT.
/// </summary>
public bool EnableSwaggerUi { get; set; }
}
}

View file

@ -0,0 +1,13 @@
namespace API.DTOs.System;
public class DirectoryDto
{
/// <summary>
/// Name of the directory
/// </summary>
public string Name { get; set; }
/// <summary>
/// Full Directory Path
/// </summary>
public string FullPath { get; set; }
}

View file

@ -7,7 +7,7 @@
/// </summary>
public int Id { get; set; }
/// <summary>
/// Url of the file to download from (can be null)
/// Base Url encoding of the file to upload from (can be null)
/// </summary>
public string Url { get; set; }
}

View file

@ -1,6 +1,7 @@
using API.DTOs.Theme;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.UserPreferences;
namespace API.DTOs
{
@ -82,5 +83,10 @@ namespace API.DTOs
/// </summary>
/// <remarks>Defaults to false</remarks>
public bool BookReaderImmersiveMode { get; set; } = false;
/// <summary>
/// Global Site Option: If the UI should layout items as Cards or List items
/// </summary>
/// <remarks>Defaults to Cards</remarks>
public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards;
}
}

View file

@ -1,10 +1,11 @@

using System;
using System.Collections.Generic;
using API.Entities.Interfaces;
namespace API.DTOs
{
public class VolumeDto
public class VolumeDto : IHasReadTimeEstimate
{
public int Id { get; set; }
public int Number { get; set; }
@ -15,5 +16,11 @@ namespace API.DTOs
public DateTime Created { get; set; }
public int SeriesId { get; set; }
public ICollection<ChapterDto> Chapters { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.MinHoursToRead"/>
public int MinHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.MaxHoursToRead"/>
public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate.AvgHoursToRead"/>
public int AvgHoursToRead { get; set; }
}
}

View file

@ -3,6 +3,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using API.Entities;
using API.Entities.Enums.UserPreferences;
using API.Entities.Interfaces;
using API.Entities.Metadata;
using Microsoft.AspNetCore.Identity;
@ -78,10 +79,14 @@ namespace API.Data
builder.Entity<AppUserPreferences>()
.Property(b => b.BackgroundColor)
.HasDefaultValue("#000000");
builder.Entity<AppUserPreferences>()
.Property(b => b.GlobalPageLayoutMode)
.HasDefaultValue(PageLayoutMode.Cards);
}
static void OnEntityTracked(object sender, EntityTrackedEventArgs e)
private static void OnEntityTracked(object sender, EntityTrackedEventArgs e)
{
if (!e.FromQuery && e.Entry.State == EntityState.Added && e.Entry.Entity is IEntityDate entity)
{
@ -91,7 +96,7 @@ namespace API.Data
}
static void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e)
private static void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e)
{
if (e.NewState == EntityState.Modified && e.Entry.Entity is IEntityDate entity)
entity.LastModified = DateTime.Now;

View file

@ -19,6 +19,9 @@ public static class MigrateBookmarks
/// </summary>
/// <remarks>Bookmark directory is configurable. This will always use the default bookmark directory.</remarks>
/// <param name="directoryService"></param>
/// <param name="unitOfWork"></param>
/// <param name="logger"></param>
/// <param name="cacheService"></param>
/// <returns></returns>
public static async Task Migrate(IDirectoryService directoryService, IUnitOfWork unitOfWork,
ILogger<Program> logger, ICacheService cacheService)

View file

@ -148,7 +148,7 @@ namespace API.Data
var volumes = await context.Volume.Include(v => v.Chapters).ToListAsync();
foreach (var volume in volumes)
{
var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), ChapterSortComparerForInChapterSorting).FirstOrDefault();
var firstChapter = volume.Chapters.MinBy(x => double.Parse(x.Number), ChapterSortComparerForInChapterSorting);
if (firstChapter == null) continue;
if (directoryService.FileSystem.File.Exists(directoryService.FileSystem.Path.Join(directoryService.CoverImageDirectory,
$"{ImageService.GetChapterFormat(firstChapter.Id, firstChapter.VolumeId)}.png")))

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,125 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class TimeEstimateInDB : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AvgHoursToRead",
table: "Volume",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MaxHoursToRead",
table: "Volume",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MinHoursToRead",
table: "Volume",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<long>(
name: "WordCount",
table: "Volume",
type: "INTEGER",
nullable: false,
defaultValue: 0L);
migrationBuilder.AddColumn<int>(
name: "AvgHoursToRead",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MaxHoursToRead",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MinHoursToRead",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "AvgHoursToRead",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MaxHoursToRead",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "MinHoursToRead",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AvgHoursToRead",
table: "Volume");
migrationBuilder.DropColumn(
name: "MaxHoursToRead",
table: "Volume");
migrationBuilder.DropColumn(
name: "MinHoursToRead",
table: "Volume");
migrationBuilder.DropColumn(
name: "WordCount",
table: "Volume");
migrationBuilder.DropColumn(
name: "AvgHoursToRead",
table: "Series");
migrationBuilder.DropColumn(
name: "MaxHoursToRead",
table: "Series");
migrationBuilder.DropColumn(
name: "MinHoursToRead",
table: "Series");
migrationBuilder.DropColumn(
name: "AvgHoursToRead",
table: "Chapter");
migrationBuilder.DropColumn(
name: "MaxHoursToRead",
table: "Chapter");
migrationBuilder.DropColumn(
name: "MinHoursToRead",
table: "Chapter");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class RenamedBookReaderLayoutMode : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "PageLayoutMode",
table: "AppUserPreferences",
newName: "BookReaderLayoutMode");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "BookReaderLayoutMode",
table: "AppUserPreferences",
newName: "PageLayoutMode");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class GlobalPageLayoutModeUserSetting : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "GlobalPageLayoutMode",
table: "AppUserPreferences",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "GlobalPageLayoutMode",
table: "AppUserPreferences");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class LastFileAnalysis : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "LastFileAnalysis",
table: "MangaFile",
type: "TEXT",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LastFileAnalysis",
table: "MangaFile");
}
}
}

View file

@ -179,6 +179,9 @@ namespace API.Data.Migrations
b.Property<bool>("BookReaderImmersiveMode")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderLayoutMode")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderLineSpacing")
.HasColumnType("INTEGER");
@ -196,10 +199,12 @@ namespace API.Data.Migrations
.HasColumnType("TEXT")
.HasDefaultValue("Dark");
b.Property<int>("LayoutMode")
.HasColumnType("INTEGER");
b.Property<int>("GlobalPageLayoutMode")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<int>("PageLayoutMode")
b.Property<int>("LayoutMode")
.HasColumnType("INTEGER");
b.Property<int>("PageSplitOption")
@ -320,6 +325,9 @@ namespace API.Data.Migrations
b.Property<int>("AgeRating")
.HasColumnType("INTEGER");
b.Property<int>("AvgHoursToRead")
.HasColumnType("INTEGER");
b.Property<int>("Count")
.HasColumnType("INTEGER");
@ -341,6 +349,12 @@ namespace API.Data.Migrations
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("MaxHoursToRead")
.HasColumnType("INTEGER");
b.Property<int>("MinHoursToRead")
.HasColumnType("INTEGER");
b.Property<string>("Number")
.HasColumnType("TEXT");
@ -502,6 +516,9 @@ namespace API.Data.Migrations
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastFileAnalysis")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
@ -732,6 +749,9 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AvgHoursToRead")
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
@ -759,6 +779,12 @@ namespace API.Data.Migrations
b.Property<bool>("LocalizedNameLocked")
.HasColumnType("INTEGER");
b.Property<int>("MaxHoursToRead")
.HasColumnType("INTEGER");
b.Property<int>("MinHoursToRead")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
@ -868,6 +894,9 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AvgHoursToRead")
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
@ -877,6 +906,12 @@ namespace API.Data.Migrations
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("MaxHoursToRead")
.HasColumnType("INTEGER");
b.Property<int>("MinHoursToRead")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
@ -889,6 +924,9 @@ namespace API.Data.Migrations
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<long>("WordCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId");

View file

@ -1,12 +1,17 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.JumpBar;
using API.DTOs.Metadata;
using API.Entities;
using API.Entities.Enums;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Kavita.Common.Extensions;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
@ -38,6 +43,10 @@ public interface ILibraryRepository
Task<LibraryType> GetLibraryTypeAsync(int libraryId);
Task<IEnumerable<Library>> GetLibraryForIdsAsync(IList<int> libraryIds);
Task<int> GetTotalFiles();
IEnumerable<JumpKeyDto> GetJumpBarAsync(int libraryId);
Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds);
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds);
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds);
}
public class LibraryRepository : ILibraryRepository
@ -123,6 +132,37 @@ public class LibraryRepository : ILibraryRepository
return await _context.MangaFile.CountAsync();
}
public IEnumerable<JumpKeyDto> GetJumpBarAsync(int libraryId)
{
var seriesSortCharacters = _context.Series.Where(s => s.LibraryId == libraryId)
.Select(s => s.SortName.ToUpper())
.OrderBy(s => s)
.AsEnumerable()
.Select(s => s[0]);
// Map the title to the number of entities
var firstCharacterMap = new Dictionary<char, int>();
foreach (var sortChar in seriesSortCharacters)
{
var c = sortChar;
var isAlpha = char.IsLetter(sortChar);
if (!isAlpha) c = '#';
if (!firstCharacterMap.ContainsKey(c))
{
firstCharacterMap[c] = 0;
}
firstCharacterMap[c] += 1;
}
return firstCharacterMap.Keys.Select(k => new JumpKeyDto()
{
Key = k + string.Empty,
Size = firstCharacterMap[k],
Title = k + string.Empty
});
}
public async Task<IEnumerable<LibraryDto>> GetLibraryDtosAsync()
{
return await _context.Library
@ -224,4 +264,54 @@ public class LibraryRepository : ILibraryRepository
}
public async Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds)
{
return await _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Select(s => s.Metadata.AgeRating)
.Distinct()
.Select(s => new AgeRatingDto()
{
Value = s,
Title = s.ToDescription()
})
.ToListAsync();
}
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds)
{
var ret = await _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Select(s => s.Metadata.Language)
.AsNoTracking()
.Distinct()
.ToListAsync();
return ret
.Where(s => !string.IsNullOrEmpty(s))
.Select(s => new LanguageDto()
{
Title = CultureInfo.GetCultureInfo(s).DisplayName,
IsoCode = s
})
.OrderBy(s => s.Title)
.ToList();
}
public IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds)
{
return _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Select(s => s.Metadata.PublicationStatus)
.Distinct()
.AsEnumerable()
.Select(s => new PublicationStatusDto()
{
Value = s,
Title = s.ToDescription()
})
.OrderBy(s => s.Title);
}
}

View file

@ -0,0 +1,27 @@
using API.Entities;
using AutoMapper;
using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
public interface IMangaFileRepository
{
void Update(MangaFile file);
}
public class MangaFileRepository : IMangaFileRepository
{
private readonly DataContext _context;
private readonly IMapper _mapper;
public MangaFileRepository(DataContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
public void Update(MangaFile file)
{
_context.Entry(file).State = EntityState.Modified;
}
}

View file

@ -17,6 +17,7 @@ using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Helpers;
using API.Services;
using API.Services.Tasks;
using AutoMapper;
using AutoMapper.QueryableExtensions;
@ -67,7 +68,8 @@ public interface ISeriesRepository
/// </summary>
/// <param name="libraryId"></param>
/// <param name="userId"></param>
/// <param name="userParams"></param>
/// <param name="userParams">Pagination info</param>
/// <param name="filter">Filtering/Sorting to apply</param>
/// <returns></returns>
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter);
/// <summary>
@ -106,13 +108,12 @@ public interface ISeriesRepository
Task<Series> GetFullSeriesForSeriesIdAsync(int seriesId);
Task<Chunk> GetChunkInfo(int libraryId = 0);
Task<IList<SeriesMetadata>> GetSeriesMetadataForIdsAsync(IEnumerable<int> seriesIds);
Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds); // TODO: Move to LibraryRepository
Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds); // TODO: Move to LibraryRepository
IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds); // TODO: Move to LibraryRepository
Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId, int pageSize = 30);
Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId);
Task<IEnumerable<SeriesDto>> GetSeriesForRelationKind(int userId, int seriesId, RelationKind kind);
Task<PagedList<SeriesDto>> GetQuickReads(int userId, int libraryId, UserParams userParams);
Task<PagedList<SeriesDto>> GetQuickCatchupReads(int userId, int libraryId, UserParams userParams);
Task<PagedList<SeriesDto>> GetHighlyRated(int userId, int libraryId, UserParams userParams);
Task<PagedList<SeriesDto>> GetMoreIn(int userId, int libraryId, int genreId, UserParams userParams);
Task<PagedList<SeriesDto>> GetRediscover(int userId, int libraryId, UserParams userParams);
@ -920,54 +921,7 @@ public class SeriesRepository : ISeriesRepository
.ToListAsync();
}
public async Task<IList<AgeRatingDto>> GetAllAgeRatingsDtosForLibrariesAsync(List<int> libraryIds)
{
return await _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Select(s => s.Metadata.AgeRating)
.Distinct()
.Select(s => new AgeRatingDto()
{
Value = s,
Title = s.ToDescription()
})
.ToListAsync();
}
public async Task<IList<LanguageDto>> GetAllLanguagesForLibrariesAsync(List<int> libraryIds)
{
var ret = await _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Select(s => s.Metadata.Language)
.AsNoTracking()
.Distinct()
.ToListAsync();
return ret
.Where(s => !string.IsNullOrEmpty(s))
.Select(s => new LanguageDto()
{
Title = CultureInfo.GetCultureInfo(s).DisplayName,
IsoCode = s
})
.OrderBy(s => s.Title)
.ToList();
}
public IEnumerable<PublicationStatusDto> GetAllPublicationStatusesDtosForLibrariesAsync(List<int> libraryIds)
{
return _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Select(s => s.Metadata.PublicationStatus)
.Distinct()
.AsEnumerable()
.Select(s => new PublicationStatusDto()
{
Value = s,
Title = s.ToDescription()
})
.OrderBy(s => s.Title);
}
/// <summary>
@ -976,6 +930,7 @@ public class SeriesRepository : ISeriesRepository
/// <remarks>This provides 2 levels of pagination. Fetching the individual chapters only looks at 3000. Then when performing grouping
/// in memory, we stop after 30 series. </remarks>
/// <param name="userId">Used to ensure user has access to libraries</param>
/// <param name="pageSize">How many entities to return</param>
/// <returns></returns>
public async Task<IEnumerable<GroupedSeriesDto>> GetRecentlyUpdatedSeries(int userId, int pageSize = 30)
{
@ -1131,7 +1086,10 @@ public class SeriesRepository : ISeriesRepository
var query = _context.Series
.Where(s => s.Pages < 2000 && !distinctSeriesIdsWithProgress.Contains(s.Id) &&
.Where(s => (
(s.Pages / ReaderService.AvgPagesPerMinute / 60 < 10 && s.Format != MangaFormat.Epub)
|| (s.WordCount * ReaderService.AvgWordsPerHour < 10 && s.Format == MangaFormat.Epub))
&& !distinctSeriesIdsWithProgress.Contains(s.Id) &&
usersSeriesIds.Contains(s.Id))
.Where(s => s.Metadata.PublicationStatus != PublicationStatus.OnGoing)
.AsSplitQuery()
@ -1141,6 +1099,30 @@ public class SeriesRepository : ISeriesRepository
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
public async Task<PagedList<SeriesDto>> GetQuickCatchupReads(int userId, int libraryId, UserParams userParams)
{
var libraryIds = GetLibraryIdsForUser(userId, libraryId);
var usersSeriesIds = GetSeriesIdsForLibraryIds(libraryIds);
var distinctSeriesIdsWithProgress = _context.AppUserProgresses
.Where(s => usersSeriesIds.Contains(s.SeriesId))
.Select(p => p.SeriesId)
.Distinct();
var query = _context.Series
.Where(s => (
(s.Pages / ReaderService.AvgPagesPerMinute / 60 < 10 && s.Format != MangaFormat.Epub)
|| (s.WordCount * ReaderService.AvgWordsPerHour < 10 && s.Format == MangaFormat.Epub))
&& !distinctSeriesIdsWithProgress.Contains(s.Id) &&
usersSeriesIds.Contains(s.Id))
.Where(s => s.Metadata.PublicationStatus == PublicationStatus.OnGoing)
.AsSplitQuery()
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider);
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
}
/// <summary>
/// Returns all library ids for a user
/// </summary>
@ -1205,7 +1187,7 @@ public class SeriesRepository : ISeriesRepository
.ToListAsync();
}
private async Task<IEnumerable<RecentlyAddedSeries>> GetRecentlyAddedChaptersQuery(int userId, int maxRecords = 3000)
private async Task<IEnumerable<RecentlyAddedSeries>> GetRecentlyAddedChaptersQuery(int userId)
{
var libraries = await _context.AppUser
.Where(u => u.Id == userId)

View file

@ -56,6 +56,7 @@ public interface IUserRepository
Task<IEnumerable<AppUser>> GetAllUsers();
Task<IEnumerable<AppUserPreferences>> GetAllPreferencesByThemeAsync(int themeId);
Task<bool> HasAccessToLibrary(int libraryId, int userId);
}
public class UserRepository : IUserRepository
@ -238,6 +239,13 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task<bool> HasAccessToLibrary(int libraryId, int userId)
{
return await _context.Library
.Include(l => l.AppUsers)
.AnyAsync(library => library.AppUsers.Any(user => user.Id == userId));
}
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
{
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);

View file

@ -101,6 +101,7 @@ namespace API.Data
new() {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory},
new() {Key = ServerSettingKey.EmailServiceUrl, Value = EmailService.DefaultApiUrl},
new() {Key = ServerSettingKey.ConvertBookmarkToWebP, Value = "false"},
new() {Key = ServerSettingKey.EnableSwaggerUi, Value = "false"},
}.ToArray());
foreach (var defaultSetting in DefaultSettings)

View file

@ -22,6 +22,7 @@ public interface IUnitOfWork
IGenreRepository GenreRepository { get; }
ITagRepository TagRepository { get; }
ISiteThemeRepository SiteThemeRepository { get; }
IMangaFileRepository MangaFileRepository { get; }
bool Commit();
Task<bool> CommitAsync();
bool HasChanges();
@ -58,6 +59,7 @@ public class UnitOfWork : IUnitOfWork
public IGenreRepository GenreRepository => new GenreRepository(_context, _mapper);
public ITagRepository TagRepository => new TagRepository(_context, _mapper);
public ISiteThemeRepository SiteThemeRepository => new SiteThemeRepository(_context, _mapper);
public IMangaFileRepository MangaFileRepository => new MangaFileRepository(_context, _mapper);
/// <summary>
/// Commits changes to the DB. Completes the open transaction.

View file

@ -1,4 +1,5 @@
using API.Entities.Enums;
using API.Entities.Enums.UserPreferences;
namespace API.Entities
{
@ -81,13 +82,17 @@ namespace API.Entities
/// 2 column is fit to height, 2 columns
/// </summary>
/// <remarks>Defaults to Default</remarks>
public BookPageLayoutMode PageLayoutMode { get; set; } = BookPageLayoutMode.Default;
public BookPageLayoutMode BookReaderLayoutMode { get; set; } = BookPageLayoutMode.Default;
/// <summary>
/// Book Reader Option: A flag that hides the menu-ing system behind a click on the screen. This should be used with tap to paginate, but the app doesn't enforce this.
/// </summary>
/// <remarks>Defaults to false</remarks>
public bool BookReaderImmersiveMode { get; set; } = false;
/// <summary>
/// Global Site Option: If the UI should layout items as Cards or List items
/// </summary>
/// <remarks>Defaults to Cards</remarks>
public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards;
public AppUser AppUser { get; set; }
public int AppUserId { get; set; }

View file

@ -3,10 +3,11 @@ using System.Collections.Generic;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Parser;
using API.Services;
namespace API.Entities
{
public class Chapter : IEntityDate
public class Chapter : IEntityDate, IHasReadTimeEstimate
{
public int Id { get; set; }
/// <summary>
@ -24,7 +25,7 @@ namespace API.Entities
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
/// <summary>
/// Absolute path to the (managed) image file
/// Relative path to the (managed) image file representing the cover image
/// </summary>
/// <remarks>The file is managed internally to Kavita's APPDIR</remarks>
public string CoverImage { get; set; }
@ -73,9 +74,16 @@ namespace API.Entities
public int Count { get; set; } = 0;
/// <summary>
/// Total words in a Chapter (books only)
/// Total Word count of all chapters in this chapter.
/// </summary>
/// <remarks>Word Count is only available from EPUB files</remarks>
public long WordCount { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate"/>
public int MinHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate"/>
public int MaxHoursToRead { get; set; }
/// <inheritdoc cref="IHasReadTimeEstimate"/>
public int AvgHoursToRead { get; set; }
/// <summary>

View file

@ -7,10 +7,6 @@
/// </summary>
Other = 1,
/// <summary>
/// Artist
/// </summary>
//Artist = 2,
/// <summary>
/// Author or Writer
/// </summary>
Writer = 3,

View file

@ -81,5 +81,10 @@ namespace API.Entities.Enums
/// </summary>
[Description("ConvertBookmarkToWebP")]
ConvertBookmarkToWebP = 14,
/// <summary>
/// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT.
/// </summary>
[Description("EnableSwaggerUi")]
EnableSwaggerUi = 15,
}
}

View file

@ -0,0 +1,11 @@
using System.ComponentModel;
namespace API.Entities.Enums.UserPreferences;
public enum PageLayoutMode
{
[Description("Cards")]
Cards = 0,
[Description("List")]
List = 1
}

View file

@ -0,0 +1,25 @@
using API.Services;
namespace API.Entities.Interfaces;
/// <summary>
/// Entity has read time estimate properties to estimate time to read
/// </summary>
public interface IHasReadTimeEstimate
{
/// <summary>
/// Min hours to read the chapter
/// </summary>
/// <remarks>Uses a fixed number to calculate from <see cref="ReaderService"/></remarks>
public int MinHoursToRead { get; set; }
/// <summary>
/// Max hours to read the chapter
/// </summary>
/// <remarks>Uses a fixed number to calculate from <see cref="ReaderService"/></remarks>
public int MaxHoursToRead { get; set; }
/// <summary>
/// Average hours to read the chapter
/// </summary>
/// <remarks>Uses a fixed number to calculate from <see cref="ReaderService"/></remarks>
public int AvgHoursToRead { get; set; }
}

View file

@ -25,6 +25,10 @@ namespace API.Entities
/// </summary>
/// <remarks>This gets updated anytime the file is scanned</remarks>
public DateTime LastModified { get; set; }
/// <summary>
/// Last time file analysis ran on this file
/// </summary>
public DateTime LastFileAnalysis { get; set; }
// Relationship Mapping

View file

@ -6,7 +6,7 @@ using API.Entities.Metadata;
namespace API.Entities;
public class Series : IEntityDate
public class Series : IEntityDate, IHasReadTimeEstimate
{
public int Id { get; set; }
/// <summary>
@ -66,10 +66,15 @@ public class Series : IEntityDate
public DateTime LastChapterAdded { get; set; }
/// <summary>
/// Total words in a Series (books only)
/// Total Word count of all chapters in this chapter.
/// </summary>
/// <remarks>Word Count is only available from EPUB files</remarks>
public long WordCount { get; set; }
public int MinHoursToRead { get; set; }
public int MaxHoursToRead { get; set; }
public int AvgHoursToRead { get; set; }
public SeriesMetadata Metadata { get; set; }
public ICollection<AppUserRating> Ratings { get; set; } = new List<AppUserRating>();
@ -87,5 +92,4 @@ public class Series : IEntityDate
public List<Volume> Volumes { get; set; }
public Library Library { get; set; }
public int LibraryId { get; set; }
}

View file

@ -1,11 +1,10 @@
using System;
using System.Collections.Generic;
using API.Entities.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace API.Entities
{
public class Volume : IEntityDate
public class Volume : IEntityDate, IHasReadTimeEstimate
{
public int Id { get; set; }
/// <summary>
@ -25,12 +24,23 @@ namespace API.Entities
/// </summary>
/// <remarks>The file is managed internally to Kavita's APPDIR</remarks>
public string CoverImage { get; set; }
/// <summary>
/// Total pages of all chapters in this volume
/// </summary>
public int Pages { get; set; }
/// <summary>
/// Total Word count of all chapters in this volume.
/// </summary>
/// <remarks>Word Count is only available from EPUB files</remarks>
public long WordCount { get; set; }
public int MinHoursToRead { get; set; }
public int MaxHoursToRead { get; set; }
public int AvgHoursToRead { get; set; }
// Relationships
public Series Series { get; set; }
public int SeriesId { get; set; }
}
}

View file

@ -113,7 +113,7 @@ namespace API.Helpers
opt.MapFrom(src => src.BookThemeName))
.ForMember(dest => dest.BookReaderLayoutMode,
opt =>
opt.MapFrom(src => src.PageLayoutMode));
opt.MapFrom(src => src.BookReaderLayoutMode));
CreateMap<AppUserBookmark, BookmarkDto>();

View file

@ -14,6 +14,7 @@ public interface ICacheHelper
bool CoverImageExists(string path);
bool HasFileNotChangedSinceCreationOrLastScan(IEntityDate chapter, bool forceUpdate, MangaFile firstFile);
bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile firstFile);
}
@ -32,6 +33,7 @@ public class CacheHelper : ICacheHelper
/// <remarks>If a cover image is locked but the underlying file has been deleted, this will allow regenerating. </remarks>
/// <param name="coverPath">This should just be the filename, no path information</param>
/// <param name="firstFile"></param>
/// <param name="chapterCreated">When the chapter was created (Not Used)</param>
/// <param name="forceUpdate">If the user has told us to force the refresh</param>
/// <param name="isCoverLocked">If cover has been locked by user. This will force false</param>
/// <returns></returns>
@ -61,6 +63,25 @@ public class CacheHelper : ICacheHelper
|| _fileService.HasFileBeenModifiedSince(firstFile.FilePath, firstFile.LastModified)));
}
/// <summary>
/// Has the file been modified since last scan or is user forcing an update
/// </summary>
/// <param name="lastScan"></param>
/// <param name="forceUpdate"></param>
/// <param name="firstFile"></param>
/// <returns></returns>
public bool HasFileChangedSinceLastScan(DateTime lastScan, bool forceUpdate, MangaFile firstFile)
{
if (firstFile == null) return false;
if (forceUpdate) return true;
return _fileService.HasFileBeenModifiedSince(firstFile.FilePath, lastScan)
|| _fileService.HasFileBeenModifiedSince(firstFile.FilePath, firstFile.LastModified);
// return firstFile != null &&
// (!forceUpdate &&
// !(_fileService.HasFileBeenModifiedSince(firstFile.FilePath, lastScan)
// || _fileService.HasFileBeenModifiedSince(firstFile.FilePath, firstFile.LastModified)));
}
/// <summary>
/// Determines if a given coverImage path exists
/// </summary>

View file

@ -51,6 +51,9 @@ namespace API.Helpers.Converters
case ServerSettingKey.ConvertBookmarkToWebP:
destination.ConvertBookmarkToWebP = bool.Parse(row.Value);
break;
case ServerSettingKey.EnableSwaggerUi:
destination.EnableSwaggerUi = bool.Parse(row.Value);
break;
}
}

View file

@ -2,14 +2,17 @@
{
public class UserParams
{
private const int MaxPageSize = 50;
public int PageNumber { get; set; } = 1;
private int _pageSize = 30;
private const int MaxPageSize = int.MaxValue;
public int PageNumber { get; init; } = 1;
private readonly int _pageSize = 30;
/// <summary>
/// If set to 0, will set as MaxInt
/// </summary>
public int PageSize
{
get => _pageSize;
set => _pageSize = (value > MaxPageSize) ? MaxPageSize : value;
init => _pageSize = (value == 0) ? MaxPageSize : value;
}
}
}

View file

@ -110,6 +110,26 @@ namespace API.Parser
new Regex(
@"(卷|册)(?<Volume>\d+)",
MatchOptions, RegexTimeout),
// Korean Volume: 제n권 -> Volume n, n권 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside)
new Regex(
@"제?(?<Volume>\d+)권",
MatchOptions, RegexTimeout),
// Korean Season: 시즌n -> Season n,
new Regex(
@"시즌(?<Volume>\d+\-?\d+)",
MatchOptions, RegexTimeout),
// Korean Season: 시즌n -> Season n, n시즌 -> season n
new Regex(
@"(?<Volume>\d+(\-|~)?\d+?)시즌",
MatchOptions, RegexTimeout),
// Korean Season: 시즌n -> Season n, n시즌 -> season n
new Regex(
@"시즌(?<Volume>\d+(\-|~)?\d+?)",
MatchOptions, RegexTimeout),
// Japanese Volume: n巻 -> Volume n
new Regex(
@"(?<Volume>\d+(?:(\-)\d+)?)巻",
MatchOptions, RegexTimeout),
};
private static readonly Regex[] MangaSeriesRegex = new[]
@ -340,6 +360,22 @@ namespace API.Parser
new Regex(
@"^(?<Series>.+?)(?:\s|_)(v|vol|tome|t)\.?(\s|_)?(?<Volume>\d+)",
MatchOptions, RegexTimeout),
// Chinese Volume: 第n卷 -> Volume n, 第n册 -> Volume n, 幽游白书完全版 第03卷 天下 or 阿衰online 第1册
new Regex(
@"第(?<Volume>\d+)(卷|册)",
MatchOptions, RegexTimeout),
// Chinese Volume: 卷n -> Volume n, 册n -> Volume n
new Regex(
@"(卷|册)(?<Volume>\d+)",
MatchOptions, RegexTimeout),
// Korean Volume: 제n권 -> Volume n, n권 -> Volume n, 63권#200.zip
new Regex(
@"제?(?<Volume>\d+)권",
MatchOptions, RegexTimeout),
// Japanese Volume: n巻 -> Volume n
new Regex(
@"(?<Volume>\d+(?:(\-)\d+)?)巻",
MatchOptions, RegexTimeout),
};
private static readonly Regex[] ComicChapterRegex = new[]
@ -398,11 +434,7 @@ namespace API.Parser
new Regex(
@"^(?<Series>.+?)-(chapter-)?(?<Chapter>\d+)",
MatchOptions, RegexTimeout),
// Cyberpunk 2077 - Your Voice 01
// new Regex(
// @"^(?<Series>.+?\s?-\s?(?:.+?))(?<Chapter>(\d+(\.\d)?)-?(\d+(\.\d)?)?)$",
// MatchOptions,
// RegexTimeout),
};
private static readonly Regex[] ReleaseGroupRegex = new[]
@ -461,7 +493,14 @@ namespace API.Parser
new Regex(
@"第(?<Chapter>\d+)话",
MatchOptions, RegexTimeout),
// Korean Chapter: 제n화 -> Chapter n, 가디언즈 오브 갤럭시 죽음의 보석.E0008.7화#44
new Regex(
@"제?(?<Chapter>\d+\.?\d+)(화|장)",
MatchOptions, RegexTimeout),
// Korean Chapter: 第10話 -> Chapter n, [ハレム]ナナとカオル 高校生のSMごっこ 第1話
new Regex(
@"第?(?<Chapter>\d+(?:.\d+|-\d+)?)話",
MatchOptions, RegexTimeout),
};
private static readonly Regex[] MangaEditionRegex = {
// Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz
@ -525,11 +564,13 @@ namespace API.Parser
MatchOptions, RegexTimeout
);
private static readonly ImmutableArray<string> FormatTagSpecialKeyowrds = ImmutableArray.Create(
private static readonly ImmutableArray<string> FormatTagSpecialKeywords = ImmutableArray.Create(
"Special", "Reference", "Director's Cut", "Box Set", "Box-Set", "Annual", "Anthology", "Epilogue",
"One Shot", "One-Shot", "Prologue", "TPB", "Trade Paper Back", "Omnibus", "Compendium", "Absolute", "Graphic Novel",
"GN", "FCBD");
private static readonly char[] LeadingZeroesTrimChars = new[] { '0' };
public static MangaFormat ParseFormat(string filePath)
{
if (IsArchive(filePath)) return MangaFormat.Archive;
@ -544,15 +585,13 @@ namespace API.Parser
foreach (var regex in MangaEditionRegex)
{
var matches = regex.Matches(filePath);
foreach (Match match in matches)
foreach (var group in matches.Select(match => match.Groups["Edition"])
.Where(group => group.Success && group != Match.Empty))
{
if (match.Groups["Edition"].Success && match.Groups["Edition"].Value != string.Empty)
{
var edition = match.Groups["Edition"].Value.Replace("{", "").Replace("}", "")
.Replace("[", "").Replace("]", "").Replace("(", "").Replace(")", "");
return edition;
}
return group.Value
.Replace("{", "").Replace("}", "")
.Replace("[", "").Replace("]", "")
.Replace("(", "").Replace(")", "");
}
}
@ -567,15 +606,8 @@ namespace API.Parser
public static bool HasSpecialMarker(string filePath)
{
var matches = SpecialMarkerRegex.Matches(filePath);
foreach (Match match in matches)
{
if (match.Groups["Special"].Success && match.Groups["Special"].Value != string.Empty)
{
return true;
}
}
return false;
return matches.Select(match => match.Groups["Special"])
.Any(group => group.Success && group != Match.Empty);
}
public static string ParseMangaSpecial(string filePath)
@ -583,12 +615,10 @@ namespace API.Parser
foreach (var regex in MangaSpecialRegex)
{
var matches = regex.Matches(filePath);
foreach (Match match in matches)
foreach (var group in matches.Select(match => match.Groups["Special"])
.Where(group => group.Success && group != Match.Empty))
{
if (match.Groups["Special"].Success && match.Groups["Special"].Value != string.Empty)
{
return match.Groups["Special"].Value;
}
return group.Value;
}
}
@ -600,12 +630,10 @@ namespace API.Parser
foreach (var regex in ComicSpecialRegex)
{
var matches = regex.Matches(filePath);
foreach (Match match in matches)
foreach (var group in matches.Select(match => match.Groups["Special"])
.Where(group => group.Success && group != Match.Empty))
{
if (match.Groups["Special"].Success && match.Groups["Special"].Value != string.Empty)
{
return match.Groups["Special"].Value;
}
return group.Value;
}
}
@ -617,12 +645,10 @@ namespace API.Parser
foreach (var regex in MangaSeriesRegex)
{
var matches = regex.Matches(filename);
foreach (Match match in matches)
foreach (var group in matches.Select(match => match.Groups["Series"])
.Where(group => group.Success && group != Match.Empty))
{
if (match.Groups["Series"].Success && match.Groups["Series"].Value != string.Empty)
{
return CleanTitle(match.Groups["Series"].Value);
}
return CleanTitle(group.Value);
}
}
@ -633,12 +659,10 @@ namespace API.Parser
foreach (var regex in ComicSeriesRegex)
{
var matches = regex.Matches(filename);
foreach (Match match in matches)
foreach (var group in matches.Select(match => match.Groups["Series"])
.Where(group => group.Success && group != Match.Empty))
{
if (match.Groups["Series"].Success && match.Groups["Series"].Value != string.Empty)
{
return CleanTitle(match.Groups["Series"].Value, true);
}
return CleanTitle(group.Value, true);
}
}
@ -668,12 +692,12 @@ namespace API.Parser
foreach (var regex in ComicVolumeRegex)
{
var matches = regex.Matches(filename);
foreach (Match match in matches)
foreach (var group in matches.Select(match => match.Groups))
{
if (!match.Groups["Volume"].Success || match.Groups["Volume"] == Match.Empty) continue;
if (!group["Volume"].Success || group["Volume"] == Match.Empty) continue;
var value = match.Groups["Volume"].Value;
var hasPart = match.Groups["Part"].Success;
var value = group["Volume"].Value;
var hasPart = group["Part"].Success;
return FormatValue(value, hasPart);
}
}
@ -779,14 +803,11 @@ namespace API.Parser
foreach (var regex in MangaSpecialRegex)
{
var matches = regex.Matches(title);
foreach (Match match in matches)
{
if (match.Success)
foreach (var match in matches.Where(m => m.Success))
{
title = title.Replace(match.Value, string.Empty).Trim();
}
}
}
return title;
}
@ -796,14 +817,11 @@ namespace API.Parser
foreach (var regex in EuropeanComicRegex)
{
var matches = regex.Matches(title);
foreach (Match match in matches)
{
if (match.Success)
foreach (var match in matches.Where(m => m.Success))
{
title = title.Replace(match.Value, string.Empty).Trim();
}
}
}
return title;
}
@ -813,14 +831,11 @@ namespace API.Parser
foreach (var regex in ComicSpecialRegex)
{
var matches = regex.Matches(title);
foreach (Match match in matches)
{
if (match.Success)
foreach (var match in matches.Where(m => m.Success))
{
title = title.Replace(match.Value, string.Empty).Trim();
}
}
}
return title;
}
@ -876,14 +891,11 @@ namespace API.Parser
foreach (var regex in ReleaseGroupRegex)
{
var matches = regex.Matches(title);
foreach (Match match in matches)
{
if (match.Success)
foreach (var match in matches.Where(m => m.Success))
{
title = title.Replace(match.Value, string.Empty);
}
}
}
return title;
}
@ -916,8 +928,8 @@ namespace API.Parser
public static string RemoveLeadingZeroes(string title)
{
var ret = title.TrimStart(new[] { '0' });
return ret == string.Empty ? "0" : ret;
var ret = title.TrimStart(LeadingZeroesTrimChars);
return string.IsNullOrEmpty(ret) ? "0" : ret;
}
public static bool IsArchive(string filePath)
@ -1060,7 +1072,7 @@ namespace API.Parser
/// <returns></returns>
public static bool HasComicInfoSpecial(string comicInfoFormat)
{
return FormatTagSpecialKeyowrds.Contains(comicInfoFormat);
return FormatTagSpecialKeywords.Contains(comicInfoFormat);
}
}
}

View file

@ -249,7 +249,7 @@ namespace API.Services
/// <summary>
/// Given an archive stream, will assess whether directory needs to be flattened so that the extracted archive files are directly
/// under extract path and not nested in subfolders. See <see cref="DirectoryInfoExtensions"/> Flatten method.
/// under extract path and not nested in subfolders. See <see cref="DirectoryService"/> Flatten method.
/// </summary>
/// <param name="archive">An opened archive stream</param>
/// <returns></returns>

View file

@ -47,6 +47,7 @@ namespace API.Services
/// </summary>
/// <param name="fileFilePath"></param>
/// <param name="targetDirectory">Where the files will be extracted to. If doesn't exist, will be created.</param>
[Obsolete("This method of reading is no longer supported. Please use native pdf reader")]
void ExtractPdfImages(string fileFilePath, string targetDirectory);
Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary<string, int> mappings, int page);
@ -246,12 +247,16 @@ namespace API.Services
private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase)
{
var images = doc.DocumentNode.SelectNodes("//img")
?? doc.DocumentNode.SelectNodes("//image");
?? doc.DocumentNode.SelectNodes("//image") ?? doc.DocumentNode.SelectNodes("//svg");
if (images == null) return;
var parent = images.First().ParentNode;
foreach (var image in images)
{
string key = null;
if (image.Attributes["src"] != null)
{
@ -269,6 +274,7 @@ namespace API.Services
image.Attributes.Add(key, $"{apiBase}" + imageFile);
// Add a custom class that the reader uses to ensure images stay within reader
parent.AddClass("kavita-scale-width-container");
image.AddClass("kavita-scale-width");
}

View file

@ -19,6 +19,7 @@ public interface IBookmarkService
Task<bool> BookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto, string imageToBookmark);
Task<bool> RemoveBookmarkPage(AppUser userWithBookmarks, BookmarkDto bookmarkDto);
Task<IEnumerable<string>> GetBookmarkFilesById(IEnumerable<int> bookmarkIds);
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
Task ConvertAllBookmarkToWebP();
}
@ -173,7 +174,6 @@ public class BookmarkService : IBookmarkService
/// <summary>
/// This is a long-running job that will convert all bookmarks into WebP. Do not invoke anyway except via Hangfire.
/// </summary>
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60 * 60), AutomaticRetry(Attempts = 0)]
public async Task ConvertAllBookmarkToWebP()
{
var bookmarkDirectory =

View file

@ -29,10 +29,10 @@ namespace API.Services
void CleanupBookmarks(IEnumerable<int> seriesIds);
string GetCachedPagePath(Chapter chapter, int page);
string GetCachedBookmarkPagePath(int seriesId, int page);
string GetCachedEpubFile(int chapterId, Chapter chapter);
string GetCachedFile(Chapter chapter);
public void ExtractChapterFiles(string extractPath, IReadOnlyList<MangaFile> files);
Task<int> CacheBookmarkForSeries(int userId, int seriesId);
void CleanupBookmarkCache(int bookmarkDtoSeriesId);
void CleanupBookmarkCache(int seriesId);
}
public class CacheService : ICacheService
{
@ -73,14 +73,13 @@ namespace API.Services
}
/// <summary>
/// Returns the full path to the cached epub file. If the file does not exist, will fallback to the original.
/// Returns the full path to the cached file. If the file does not exist, will fallback to the original.
/// </summary>
/// <param name="chapterId"></param>
/// <param name="chapter"></param>
/// <returns></returns>
public string GetCachedEpubFile(int chapterId, Chapter chapter)
public string GetCachedFile(Chapter chapter)
{
var extractPath = GetCachePath(chapterId);
var extractPath = GetCachePath(chapter.Id);
var path = Path.Join(extractPath, _directoryService.FileSystem.Path.GetFileName(chapter.Files.First().FilePath));
if (!(_directoryService.FileSystem.FileInfo.FromFileName(path).Exists))
{
@ -89,6 +88,7 @@ namespace API.Services
return path;
}
/// <summary>
/// Caches the files for the given chapter to CacheDirectory
/// </summary>
@ -136,25 +136,25 @@ namespace API.Services
extraPath = file.Id + string.Empty;
}
if (file.Format == MangaFormat.Archive)
switch (file.Format)
{
case MangaFormat.Archive:
_readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), file.Format);
}
else if (file.Format == MangaFormat.Pdf)
{
_readingItemService.Extract(file.FilePath, Path.Join(extractPath, extraPath), file.Format);
}
else if (file.Format == MangaFormat.Epub)
break;
case MangaFormat.Epub:
case MangaFormat.Pdf:
{
removeNonImages = false;
if (!_directoryService.FileSystem.File.Exists(files[0].FilePath))
{
_logger.LogError("{Archive} does not exist on disk", files[0].FilePath);
_logger.LogError("{File} does not exist on disk", files[0].FilePath);
throw new KavitaException($"{files[0].FilePath} does not exist on disk");
}
_directoryService.ExistOrCreate(extractPath);
_directoryService.CopyFileToDirectory(files[0].FilePath, extractPath);
break;
}
}
}

View file

@ -6,6 +6,8 @@ using System.IO.Abstractions;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.DTOs.System;
using API.Entities.Enums;
using API.Extensions;
using Microsoft.Extensions.Logging;
@ -29,7 +31,7 @@ namespace API.Services
/// </summary>
/// <param name="rootPath">Absolute path of directory to scan.</param>
/// <returns>List of folder names</returns>
IEnumerable<string> ListDirectory(string rootPath);
IEnumerable<DirectoryDto> ListDirectory(string rootPath);
Task<byte[]> ReadFileAsync(string path);
bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "");
bool Exists(string directory);
@ -434,14 +436,18 @@ namespace API.Services
/// </summary>
/// <param name="rootPath"></param>
/// <returns></returns>
public IEnumerable<string> ListDirectory(string rootPath)
public IEnumerable<DirectoryDto> ListDirectory(string rootPath)
{
if (!FileSystem.Directory.Exists(rootPath)) return ImmutableList<string>.Empty;
if (!FileSystem.Directory.Exists(rootPath)) return ImmutableList<DirectoryDto>.Empty;
var di = FileSystem.DirectoryInfo.FromDirectoryName(rootPath);
var dirs = di.GetDirectories()
.Where(dir => !(dir.Attributes.HasFlag(FileAttributes.Hidden) || dir.Attributes.HasFlag(FileAttributes.System)))
.Select(d => d.Name).ToImmutableList();
.Select(d => new DirectoryDto()
{
Name = d.Name,
FullPath = d.FullName,
}).ToImmutableList();
return dirs;
}
@ -724,7 +730,7 @@ namespace API.Services
FileSystem.Path.Join(directoryName, "test.txt"),
string.Empty);
}
catch (Exception ex)
catch (Exception)
{
ClearAndDeleteDirectory(directoryName);
return false;

View file

@ -50,7 +50,7 @@ public class ImageService : IImageService
_directoryService = directoryService;
}
public void ExtractImages(string fileFilePath, string targetDirectory, int fileCount)
public void ExtractImages(string fileFilePath, string targetDirectory, int fileCount = 1)
{
_directoryService.ExistOrCreate(targetDirectory);
if (fileCount == 1)

View file

@ -27,13 +27,15 @@ public interface IMetadataService
/// </summary>
/// <param name="libraryId"></param>
/// <param name="forceUpdate"></param>
[DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
Task RefreshMetadata(int libraryId, bool forceUpdate = false);
/// <summary>
/// Performs a forced refresh of metadata just for a series and it's nested entities
/// </summary>
/// <param name="libraryId"></param>
/// <param name="seriesId"></param>
Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = false);
Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = true);
}
public class MetadataService : IMetadataService
@ -196,8 +198,6 @@ public class MetadataService : IMetadataService
/// <remarks>This can be heavy on memory first run</remarks>
/// <param name="libraryId"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
[DisableConcurrentExecution(timeoutInSeconds: 360)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task RefreshMetadata(int libraryId, bool forceUpdate = false)
{
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None);

View file

@ -7,6 +7,7 @@ using API.Comparators;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Reader;
using API.Entities;
using API.Extensions;
using API.SignalR;
@ -28,6 +29,7 @@ public interface IReaderService
Task<ChapterDto> GetContinuePoint(int seriesId, int userId);
Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber);
Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber);
HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub);
}
public class ReaderService : IReaderService
@ -38,6 +40,14 @@ public class ReaderService : IReaderService
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
public const float MinWordsPerHour = 10260F;
public const float MaxWordsPerHour = 30000F;
public const float AvgWordsPerHour = (MaxWordsPerHour + MinWordsPerHour) / 2F;
public const float MinPagesPerMinute = 3.33F;
public const float MaxPagesPerMinute = 2.75F;
public const float AvgPagesPerMinute = (MaxPagesPerMinute + MinPagesPerMinute) / 2F;
public ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger, IEventHub eventHub)
{
_unitOfWork = unitOfWork;
@ -160,7 +170,7 @@ public class ReaderService : IReaderService
var progresses = user.Progresses.Where(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id).ToList();
if (progresses.Count > 1)
{
user.Progresses = new List<AppUserProgress>()
user.Progresses = new List<AppUserProgress>
{
user.Progresses.First()
};
@ -320,7 +330,7 @@ public class ReaderService : IReaderService
{
var chapterVolume = volumes.FirstOrDefault();
if (chapterVolume?.Number != 0) return -1;
var firstChapter = chapterVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).FirstOrDefault();
var firstChapter = chapterVolume.Chapters.MinBy(x => double.Parse(x.Number), _chapterSortComparer);
if (firstChapter == null) return -1;
return firstChapter.Id;
}
@ -362,17 +372,16 @@ public class ReaderService : IReaderService
if (volume.Number == currentVolume.Number - 1)
{
if (currentVolume.Number - 1 == 0) break; // If we have walked all the way to chapter volume, then we should break so logic outside can work
var lastChapter = volume.Chapters
.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).LastOrDefault();
var lastChapter = volume.Chapters.MaxBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting);
if (lastChapter == null) return -1;
return lastChapter.Id;
}
}
var lastVolume = volumes.OrderBy(v => v.Number).LastOrDefault();
var lastVolume = volumes.MaxBy(v => v.Number);
if (currentVolume.Number == 0 && currentVolume.Number != lastVolume?.Number && lastVolume?.Chapters.Count > 1)
{
var lastChapter = lastVolume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).LastOrDefault();
var lastChapter = lastVolume.Chapters.MaxBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting);
if (lastChapter == null) return -1;
return lastChapter.Id;
}
@ -396,7 +405,7 @@ public class ReaderService : IReaderService
if (progress.Count == 0)
{
// I think i need a way to sort volumes last
return volumes.OrderBy(v => double.Parse(v.Number + ""), _chapterSortComparer).First().Chapters
return volumes.OrderBy(v => double.Parse(v.Number + string.Empty), _chapterSortComparer).First().Chapters
.OrderBy(c => float.Parse(c.Number)).First();
}
@ -470,7 +479,7 @@ public class ReaderService : IReaderService
/// <param name="chapterNumber"></param>
public async Task MarkChaptersUntilAsRead(AppUser user, int seriesId, float chapterNumber)
{
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int>() { seriesId }, true);
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int> { seriesId }, true);
foreach (var volume in volumes.OrderBy(v => v.Number))
{
var chapters = volume.Chapters
@ -482,10 +491,53 @@ public class ReaderService : IReaderService
public async Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber)
{
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int>() { seriesId }, true);
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(new List<int> { seriesId }, true);
foreach (var volume in volumes.OrderBy(v => v.Number).Where(v => v.Number <= volumeNumber && v.Number > 0))
{
MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters);
}
}
public HourEstimateRangeDto GetTimeEstimate(long wordCount, int pageCount, bool isEpub)
{
if (isEpub)
{
var minHours = Math.Max((int) Math.Round((wordCount / MinWordsPerHour)), 0);
var maxHours = Math.Max((int) Math.Round((wordCount / MaxWordsPerHour)), 0);
if (maxHours < minHours)
{
return new HourEstimateRangeDto
{
MinHours = maxHours,
MaxHours = minHours,
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour))
};
}
return new HourEstimateRangeDto
{
MinHours = minHours,
MaxHours = maxHours,
AvgHours = (int) Math.Round((wordCount / AvgWordsPerHour))
};
}
var minHoursPages = Math.Max((int) Math.Round((pageCount / MinPagesPerMinute / 60F)), 0);
var maxHoursPages = Math.Max((int) Math.Round((pageCount / MaxPagesPerMinute / 60F)), 0);
if (maxHoursPages < minHoursPages)
{
return new HourEstimateRangeDto
{
MinHours = maxHoursPages,
MaxHours = minHoursPages,
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F))
};
}
return new HourEstimateRangeDto
{
MinHours = minHoursPages,
MaxHours = maxHoursPages,
AvgHours = (int) Math.Round((pageCount / AvgPagesPerMinute / 60F))
};
}
}

View file

@ -110,15 +110,13 @@ public class ReadingItemService : IReadingItemService
{
switch (format)
{
case MangaFormat.Pdf:
_bookService.ExtractPdfImages(fileFilePath, targetDirectory);
break;
case MangaFormat.Archive:
_archiveService.ExtractArchive(fileFilePath, targetDirectory);
break;
case MangaFormat.Image:
_imageService.ExtractImages(fileFilePath, targetDirectory, imageCount);
break;
case MangaFormat.Pdf:
case MangaFormat.Unknown:
case MangaFormat.Epub:
break;

View file

@ -8,9 +8,10 @@ using API.Data;
using API.DTOs;
using API.DTOs.CollectionTags;
using API.DTOs.Metadata;
using API.DTOs.Reader;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.SignalR;
using Microsoft.Extensions.Logging;
@ -98,7 +99,7 @@ public class SeriesService : ISeriesService
series.Metadata.SummaryLocked = true;
}
if (series.Metadata.Language != updateSeriesMetadataDto.SeriesMetadata.Language)
if (series.Metadata.Language != updateSeriesMetadataDto.SeriesMetadata?.Language)
{
series.Metadata.Language = updateSeriesMetadataDto.SeriesMetadata?.Language;
series.Metadata.LanguageLocked = true;
@ -112,7 +113,7 @@ public class SeriesService : ISeriesService
});
series.Metadata.Genres ??= new List<Genre>();
UpdateGenreList(updateSeriesMetadataDto.SeriesMetadata.Genres, series, allGenres, (genre) =>
UpdateGenreList(updateSeriesMetadataDto.SeriesMetadata?.Genres, series, allGenres, (genre) =>
{
series.Metadata.Genres.Add(genre);
}, () => series.Metadata.GenresLocked = true);
@ -458,7 +459,6 @@ public class SeriesService : ISeriesService
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId))
.OrderBy(v => Parser.Parser.MinNumberFromRange(v.Name))
.ToList();
var chapters = volumes.SelectMany(v => v.Chapters).ToList();
// For books, the Name of the Volume is remapped to the actual name of the book, rather than Volume number.
var processedVolumes = new List<VolumeDto>();
@ -479,8 +479,15 @@ public class SeriesService : ISeriesService
processedVolumes.ForEach(v => v.Name = $"Volume {v.Name}");
}
var specials = new List<ChapterDto>();
var chapters = volumes.SelectMany(v => v.Chapters.Select(c =>
{
if (v.Number == 0) return c;
c.VolumeTitle = v.Name;
return c;
})).ToList();
foreach (var chapter in chapters)
{
chapter.Title = FormatChapterTitle(chapter, libraryType);
@ -490,7 +497,6 @@ public class SeriesService : ISeriesService
specials.Add(chapter);
}
// Don't show chapter 0 (aka single volume chapters) in the Chapters tab or books that are just single numbers (they show as volumes)
IEnumerable<ChapterDto> retChapters;
if (libraryType == LibraryType.Book)
@ -503,29 +509,28 @@ public class SeriesService : ISeriesService
.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer());
}
var storylineChapters = volumes
.Where(v => v.Number == 0)
.SelectMany(v => v.Chapters.Where(c => !c.IsSpecial))
.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer());
return new SeriesDetailDto()
{
Specials = specials,
Chapters = retChapters,
Volumes = processedVolumes,
StorylineChapters = volumes
.Where(v => v.Number == 0)
.SelectMany(v => v.Chapters.Where(c => !c.IsSpecial))
.OrderBy(c => float.Parse(c.Number), new ChapterSortComparer())
StorylineChapters = storylineChapters
};
}
/// <summary>
/// Should we show the given chapter on the UI. We only show non-specials and non-zero chapters.
/// </summary>
/// <param name="c"></param>
/// <param name="chapter"></param>
/// <returns></returns>
private static bool ShouldIncludeChapter(ChapterDto c)
private static bool ShouldIncludeChapter(ChapterDto chapter)
{
return !c.IsSpecial && !c.Number.Equals(Parser.Parser.DefaultChapter);
return !chapter.IsSpecial && !chapter.Number.Equals(Parser.Parser.DefaultChapter);
}
public static void RenameVolumeName(ChapterDto firstChapter, VolumeDto volume, LibraryType libraryType)

View file

@ -180,7 +180,7 @@ public class TaskScheduler : ITaskScheduler
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, forceUpdate));
}
public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = true)
public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false)
{
_logger.LogInformation("Enqueuing series metadata refresh for: {SeriesId}", seriesId);
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, seriesId, forceUpdate));

View file

@ -45,11 +45,6 @@ public class BackupService : IBackupService
_config = config;
_eventHub = eventHub;
// var maxRollingFiles = config.GetMaxRollingFiles();
// var loggingSection = config.GetLoggingFileName();
// var files = GetLogFiles(maxRollingFiles, loggingSection);
_backupFiles = new List<string>()
{
"appsettings.json",
@ -59,11 +54,6 @@ public class BackupService : IBackupService
"kavita.db-shm", // This wont always be there
"kavita.db-wal" // This wont always be there
};
// foreach (var file in files.Select(f => (_directoryService.FileSystem.FileInfo.FromFileName(f)).Name))
// {
// _backupFiles.Add(file);
// }
}
public IEnumerable<string> GetLogFiles(int maxRollingFiles, string logFileName)

View file

@ -17,8 +17,10 @@ namespace API.Services.Tasks.Metadata;
public interface IWordCountAnalyzerService
{
[DisableConcurrentExecution(timeoutInSeconds: 60 * 60 * 60)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
Task ScanLibrary(int libraryId, bool forceUpdate = false);
Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = true);
}
/// <summary>
@ -30,18 +32,19 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
private readonly IUnitOfWork _unitOfWork;
private readonly IEventHub _eventHub;
private readonly ICacheHelper _cacheHelper;
private readonly IReaderService _readerService;
public WordCountAnalyzerService(ILogger<WordCountAnalyzerService> logger, IUnitOfWork unitOfWork, IEventHub eventHub,
ICacheHelper cacheHelper)
ICacheHelper cacheHelper, IReaderService readerService)
{
_logger = logger;
_unitOfWork = unitOfWork;
_eventHub = eventHub;
_cacheHelper = cacheHelper;
_readerService = readerService;
}
[DisableConcurrentExecution(timeoutInSeconds: 360)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibrary(int libraryId, bool forceUpdate = false)
{
var sw = Stopwatch.StartNew();
@ -52,7 +55,6 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id);
var stopwatch = Stopwatch.StartNew();
var totalTime = 0L;
_logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize);
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
@ -61,7 +63,6 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++)
{
if (chunkInfo.TotalChunks == 0) continue;
totalTime += stopwatch.ElapsedMilliseconds;
stopwatch.Restart();
_logger.LogInformation("[MetadataService] Processing chunk {ChunkNumber} / {TotalChunks} with size {ChunkSize}. Series ({SeriesStart} - {SeriesEnd}",
@ -113,7 +114,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
}
public async Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = false)
public async Task ScanSeries(int libraryId, int seriesId, bool forceUpdate = true)
{
var sw = Stopwatch.StartNew();
var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId);
@ -126,7 +127,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.WordCountAnalyzerProgressEvent(libraryId, 0F, ProgressEventType.Started, series.Name));
await ProcessSeries(series);
await ProcessSeries(series, forceUpdate);
if (_unitOfWork.HasChanges())
{
@ -141,25 +142,31 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
private async Task ProcessSeries(Series series, bool forceUpdate = false, bool useFileName = true)
{
if (series.Format != MangaFormat.Epub) return;
long totalSum = 0;
foreach (var chapter in series.Volumes.SelectMany(v => v.Chapters))
var isEpub = series.Format == MangaFormat.Epub;
series.WordCount = 0;
foreach (var volume in series.Volumes)
{
volume.WordCount = 0;
foreach (var chapter in volume.Chapters)
{
// This compares if it's changed since a file scan only
if (!_cacheHelper.HasFileNotChangedSinceCreationOrLastScan(chapter, false,
chapter.Files.FirstOrDefault()) && chapter.WordCount != 0)
var firstFile = chapter.Files.FirstOrDefault();
if (firstFile == null) return;
if (!_cacheHelper.HasFileChangedSinceLastScan(firstFile.LastFileAnalysis, forceUpdate,
firstFile))
continue;
if (series.Format == MangaFormat.Epub)
{
long sum = 0;
var fileCounter = 1;
foreach (var file in chapter.Files.Select(file => file.FilePath))
foreach (var file in chapter.Files)
{
var filePath = file.FilePath;
var pageCounter = 1;
try
{
using var book = await EpubReader.OpenBookAsync(file, BookService.BookReaderOptions);
using var book = await EpubReader.OpenBookAsync(filePath, BookService.BookReaderOptions);
var totalPages = book.Content.Html.Values;
foreach (var bookPage in totalPages)
@ -169,7 +176,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.WordCountAnalyzerProgressEvent(series.LibraryId, progress,
ProgressEventType.Updated, useFileName ? file : series.Name));
ProgressEventType.Updated, useFileName ? filePath : series.Name));
sum += await GetWordCountFromHtml(bookPage);
pageCounter++;
}
@ -185,14 +192,34 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
return;
}
file.LastFileAnalysis = DateTime.Now;
_unitOfWork.MangaFileRepository.Update(file);
}
chapter.WordCount = sum;
_unitOfWork.ChapterRepository.Update(chapter);
totalSum += sum;
series.WordCount += sum;
volume.WordCount += sum;
}
series.WordCount = totalSum;
var est = _readerService.GetTimeEstimate(chapter.WordCount, chapter.Pages, isEpub);
chapter.MinHoursToRead = est.MinHours;
chapter.MaxHoursToRead = est.MaxHours;
chapter.AvgHoursToRead = est.AvgHours;
_unitOfWork.ChapterRepository.Update(chapter);
}
var volumeEst = _readerService.GetTimeEstimate(volume.WordCount, volume.Pages, isEpub);
volume.MinHoursToRead = volumeEst.MinHours;
volume.MaxHoursToRead = volumeEst.MaxHours;
volume.AvgHoursToRead = volumeEst.AvgHours;
_unitOfWork.VolumeRepository.Update(volume);
}
var seriesEstimate = _readerService.GetTimeEstimate(series.WordCount, series.Pages, isEpub);
series.MinHoursToRead = seriesEstimate.MinHours;
series.MaxHoursToRead = seriesEstimate.MaxHours;
series.AvgHoursToRead = seriesEstimate.AvgHours;
_unitOfWork.SeriesRepository.Update(series);
}
@ -206,8 +233,7 @@ public class WordCountAnalyzerService : IWordCountAnalyzerService
if (textNodes == null) return 0;
return textNodes
.Select(node => node.InnerText)
.Select(text => text.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(node => node.InnerText.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(s => char.IsLetter(s[0])))
.Select(words => words.Count())
.Where(wordCount => wordCount > 0)

View file

@ -164,10 +164,15 @@ namespace API.Services.Tasks.Scanner
info.Series = MergeName(info);
var normalizedSeries = Parser.Parser.Normalize(info.Series);
var normalizedSortSeries = Parser.Parser.Normalize(info.SeriesSort);
var normalizedLocalizedSeries = Parser.Parser.Normalize(info.LocalizedSeries);
var existingKey = _scannedSeries.Keys.FirstOrDefault(ps =>
ps.Format == info.Format && (ps.NormalizedName == normalizedSeries
|| ps.NormalizedName == normalizedLocalizedSeries));
try
{
var existingKey = _scannedSeries.Keys.SingleOrDefault(ps =>
ps.Format == info.Format && (ps.NormalizedName.Equals(normalizedSeries)
|| ps.NormalizedName.Equals(normalizedLocalizedSeries)
|| ps.NormalizedName.Equals(normalizedSortSeries)));
existingKey ??= new ParsedSeries()
{
Format = info.Format,
@ -186,6 +191,18 @@ namespace API.Services.Tasks.Scanner
return oldValue;
});
}
catch (Exception ex)
{
_logger.LogCritical(ex, "{SeriesName} matches against multiple series in the parsed series. This indicates a critical kavita issue. Key will be skipped", info.Series);
foreach (var seriesKey in _scannedSeries.Keys.Where(ps =>
ps.Format == info.Format && (ps.NormalizedName.Equals(normalizedSeries)
|| ps.NormalizedName.Equals(normalizedLocalizedSeries)
|| ps.NormalizedName.Equals(normalizedSortSeries))))
{
_logger.LogCritical("Matches: {SeriesName} matches on {SeriesKey}", info.Series, seriesKey.Name);
}
}
}
/// <summary>
/// Using a normalized name from the passed ParserInfo, this checks against all found series so far and if an existing one exists with
@ -198,15 +215,33 @@ namespace API.Services.Tasks.Scanner
var normalizedSeries = Parser.Parser.Normalize(info.Series);
var normalizedLocalSeries = Parser.Parser.Normalize(info.LocalizedSeries);
// We use FirstOrDefault because this was introduced late in development and users might have 2 series with both names
try
{
var existingName =
_scannedSeries.FirstOrDefault(p =>
_scannedSeries.SingleOrDefault(p =>
(Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedSeries ||
Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedLocalSeries) && p.Key.Format == info.Format)
Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedLocalSeries) &&
p.Key.Format == info.Format)
.Key;
if (existingName != null && !string.IsNullOrEmpty(existingName.Name))
{
return existingName.Name;
}
}
catch (Exception ex)
{
_logger.LogCritical(ex, "Multiple series detected for {SeriesName} ({File})! This is critical to fix! There should only be 1", info.Series, info.FullFilePath);
var values = _scannedSeries.Where(p =>
(Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedSeries ||
Parser.Parser.Normalize(p.Key.NormalizedName) == normalizedLocalSeries) &&
p.Key.Format == info.Format);
foreach (var pair in values)
{
_logger.LogCritical("Duplicate Series in DB matches with {SeriesName}: {DuplicateName}", info.Series, pair.Key.Name);
}
}
return info.Series;
}

View file

@ -28,8 +28,14 @@ public interface IScannerService
/// cover images if forceUpdate is true.
/// </summary>
/// <param name="libraryId">Library to scan against</param>
[DisableConcurrentExecution(60 * 60 * 60)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
Task ScanLibrary(int libraryId);
[DisableConcurrentExecution(60 * 60 * 60)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
Task ScanLibraries();
[DisableConcurrentExecution(60 * 60 * 60)]
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
Task ScanSeries(int libraryId, int seriesId, CancellationToken token);
}
@ -63,8 +69,6 @@ public class ScannerService : IScannerService
_wordCountAnalyzerService = wordCountAnalyzerService;
}
[DisableConcurrentExecution(timeoutInSeconds: 360)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanSeries(int libraryId, int seriesId, CancellationToken token)
{
var sw = new Stopwatch();
@ -247,8 +251,6 @@ public class ScannerService : IScannerService
}
[DisableConcurrentExecution(timeoutInSeconds: 360)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibraries()
{
_logger.LogInformation("Starting Scan of All Libraries");
@ -267,8 +269,7 @@ public class ScannerService : IScannerService
/// ie) all entities will be rechecked for new cover images and comicInfo.xml changes
/// </summary>
/// <param name="libraryId"></param>
[DisableConcurrentExecution(360)]
[AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
public async Task ScanLibrary(int libraryId)
{
Library library;
@ -470,6 +471,7 @@ public class ScannerService : IScannerService
foreach (var series in duplicateSeries)
{
_logger.LogCritical("[ScannerService] Duplicate Series Found: {Key} maps with {Series}", key.Name, series.OriginalName);
}
continue;
@ -770,7 +772,6 @@ public class ScannerService : IScannerService
case PersonRole.Translator:
if (!series.Metadata.TranslatorLocked) series.Metadata.People.Remove(person);
break;
case PersonRole.Other:
default:
series.Metadata.People.Remove(person);
break;

View file

@ -176,13 +176,23 @@ namespace API
app.UseMiddleware<ExceptionMiddleware>();
if (env.IsDevelopment())
Task.Run(async () =>
{
var allowSwaggerUi = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync())
.EnableSwaggerUi;
if (env.IsDevelopment() || allowSwaggerUi)
{
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "Kavita API " + BuildInfo.Version);
});
}
});
if (env.IsDevelopment())
{
app.UseHangfireDashboard();
}

View file

@ -5,7 +5,7 @@
"TokenKey": "super secret unguessable key",
"Logging": {
"LogLevel": {
"Default": "Information",
"Default": "Critical",
"Microsoft": "Information",
"Microsoft.Hosting.Lifetime": "Error",
"Hangfire": "Information",

View file

@ -4,7 +4,7 @@
<TargetFramework>net6.0</TargetFramework>
<Company>kavitareader.com</Company>
<Product>Kavita</Product>
<AssemblyVersion>0.5.3.6</AssemblyVersion>
<AssemblyVersion>0.5.3.19</AssemblyVersion>
<NeutralLanguage>en</NeutralLanguage>
</PropertyGroup>
@ -12,7 +12,7 @@
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="6.0.1" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.39.0.47922">
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.40.0.48530">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View file

@ -2,5 +2,6 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=1BC0273F_002DFEBE_002D4DA1_002DBC04_002D3A3167E4C86C_002Fd_003AData_002Fd_003AMigrations/@EntryIndexedValue">ExplicitlyExcluded</s:String>
<s:Boolean x:Key="/Default/CodeInspection/Highlighting/RunLongAnalysisInSwa/@EntryValue">True</s:Boolean>
<s:Boolean x:Key="/Default/CodeInspection/Highlighting/RunValueAnalysisInNullableWarningsEnabledContext2/@EntryValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Omake/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Opds/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=rewinded/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View file

@ -30,7 +30,12 @@
"tsConfig": "tsconfig.app.json",
"assets": [
"src/assets",
"src/site.webmanifest"
"src/site.webmanifest",
{
"glob": "**/*",
"input": "node_modules/ngx-extended-pdf-viewer/assets/",
"output": "/assets/"
}
],
"sourceMap": {
"hidden": false,

View file

@ -4,7 +4,7 @@
"compilerOptions": {
"outDir": "../out-tsc/e2e",
"module": "commonjs",
"target": "es2018",
"target": "es2020",
"types": [
"jasmine",
"node"

1906
UI/Web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -27,18 +27,21 @@
"@angular/platform-browser-dynamic": "~13.2.2",
"@angular/router": "~13.2.2",
"@fortawesome/fontawesome-free": "^6.0.0",
"@iharbeck/ngx-virtual-scroller": "^13.0.4",
"@microsoft/signalr": "^6.0.2",
"@ng-bootstrap/ng-bootstrap": "^12.0.0",
"@ng-bootstrap/ng-bootstrap": "^12.1.2",
"@popperjs/core": "^2.11.2",
"@types/file-saver": "^2.0.5",
"bootstrap": "^5.1.2",
"bowser": "^2.11.0",
"eventsource": "^1.1.1",
"eventsource": "^2.0.2",
"file-saver": "^2.0.5",
"lazysizes": "^5.3.2",
"ng-circle-progress": "^1.6.0",
"ngx-color-picker": "^12.0.0",
"ngx-extended-pdf-viewer": "^13.5.2",
"ngx-file-drop": "^13.0.0",
"ngx-infinite-scroll": "^13.0.2",
"ngx-toastr": "^14.2.1",
"requires": "^1.0.2",
"rxjs": "~7.5.4",

View file

@ -17,6 +17,8 @@ export interface ChapterMetadata {
summary: string;
count: number;
totalCount: number;
wordCount: number;
genres: Array<Genre>;

View file

@ -1,4 +1,7 @@
import { HourEstimateRange } from './hour-estimate-range';
import { MangaFile } from './manga-file';
import { AgeRating } from './metadata/age-rating';
import { AgeRatingDto } from './metadata/age-rating-dto';
/**
* Chapter table object. This does not have metadata on it, use ChapterMetadata which is the same Chapter but with those fields.
@ -23,4 +26,19 @@ export interface Chapter {
* Actual name of the Chapter if populated in underlying metadata
*/
titleName: string;
/**
* Summary for the chapter
*/
summary?: string;
minHoursToRead: number;
maxHoursToRead: number;
avgHoursToRead: number;
ageRating: AgeRating;
releaseDate: string;
wordCount: number;
/**
* 'Volume number'. Only available for SeriesDetail
*/
volumeTitle?: string;
}

View file

@ -0,0 +1,6 @@
export interface HourEstimateRange{
minHours: number;
maxHours: number;
avgHours: number;
//hasProgress: boolean;
}

View file

@ -0,0 +1,5 @@
export interface JumpKey {
size: number;
key: string;
title: string;
}

View file

@ -0,0 +1,10 @@
export enum PageLayoutMode {
/**
* Use Cards for laying out data
*/
Cards = 0,
/**
* Use list style for laying out items
*/
List = 1
}

View file

@ -1,6 +1,7 @@
import { LayoutMode } from 'src/app/manga-reader/_models/layout-mode';
import { BookPageLayoutMode } from '../book-page-layout-mode';
import { PageLayoutMode } from '../page-layout-mode';
import { PageSplitOption } from './page-split-option';
import { ReaderMode } from './reader-mode';
import { ReadingDirection } from './reading-direction';
@ -31,6 +32,7 @@ export interface Preferences {
// Global
theme: SiteTheme;
globalPageLayoutMode: PageLayoutMode;
}
export const readingDirections = [{text: 'Left to Right', value: ReadingDirection.LeftToRight}, {text: 'Right to Left', value: ReadingDirection.RightToLeft}];
@ -39,3 +41,4 @@ export const pageSplitOptions = [{text: 'Fit to Screen', value: PageSplitOption.
export const readingModes = [{text: 'Left to Right', value: ReaderMode.LeftRight}, {text: 'Up to Down', value: ReaderMode.UpDown}, {text: 'Webtoon', value: ReaderMode.Webtoon}];
export const layoutModes = [{text: 'Single', value: LayoutMode.Single}, {text: 'Double', value: LayoutMode.Double}, {text: 'Double (Manga)', value: LayoutMode.DoubleReversed}];
export const bookLayoutModes = [{text: 'Default', value: BookPageLayoutMode.Default}, {text: '1 Column', value: BookPageLayoutMode.Column1}, {text: '2 Column', value: BookPageLayoutMode.Column2}];
export const pageLayoutModes = [{text: 'Cards', value: PageLayoutMode.Cards}, {text: 'List', value: PageLayoutMode.List}];

View file

@ -52,4 +52,7 @@ export interface Series {
* Number of words in the series
*/
wordCount: number;
minHoursToRead: number;
maxHoursToRead: number;
avgHoursToRead: number;
}

View file

@ -0,0 +1,4 @@
export interface DirectoryDto {
name: string;
fullPath: string;
}

View file

@ -1,4 +1,5 @@
import { Chapter } from './chapter';
import { HourEstimateRange } from './hour-estimate-range';
export interface Volume {
id: number;
@ -8,5 +9,12 @@ export interface Volume {
lastModified: string;
pages: number;
pagesRead: number;
chapters: Array<Chapter>; // TODO: Validate any cases where this is undefined
chapters: Array<Chapter>;
/**
* This is only available on the object when fetched for SeriesDetail
*/
timeEstimate?: HourEstimateRange;
minHoursToRead: number;
maxHoursToRead: number;
avgHoursToRead: number;
}

View file

@ -65,6 +65,10 @@ export enum Action {
* Open Series detail page for said series
*/
ViewSeries = 13,
/**
* Open the reader for entity
*/
Read = 14,
}
export interface ActionItem<T> {

View file

@ -2,7 +2,7 @@ import { Injectable, OnDestroy } from '@angular/core';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { Subject } from 'rxjs';
import { take } from 'rxjs/operators';
import { finalize, take, takeWhile } from 'rxjs/operators';
import { BulkAddToCollectionComponent } from '../cards/_modals/bulk-add-to-collection/bulk-add-to-collection.component';
import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component';
import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component';
@ -527,5 +527,4 @@ export class ActionService implements OnDestroy {
}
});
}
}

View file

@ -3,8 +3,10 @@ import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { map, take } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { JumpKey } from '../_models/jumpbar/jump-key';
import { Library, LibraryType } from '../_models/library';
import { SearchResultGroup } from '../_models/search/search-result-group';
import { DirectoryDto } from '../_models/system/directory-dto';
@Injectable({
@ -55,7 +57,11 @@ export class LibraryService {
query = '?path=' + encodeURIComponent(rootPath);
}
return this.httpClient.get<string[]>(this.baseUrl + 'library/list' + query);
return this.httpClient.get<DirectoryDto[]>(this.baseUrl + 'library/list' + query);
}
getJumpBar(libraryId: number) {
return this.httpClient.get<JumpKey[]>(this.baseUrl + 'library/jump-bar?libraryId=' + libraryId);
}
getLibraries() {

View file

@ -67,6 +67,10 @@ export enum EVENTS {
* When bulk bookmarks are being converted
*/
ConvertBookmarksProgress = 'ConvertBookmarksProgress',
/**
* When files are being scanned to calculate word count
*/
WordCountAnalyzerProgress = 'WordCountAnalyzerProgress'
}
export interface Message<T> {
@ -155,6 +159,13 @@ export class MessageHubService {
});
});
this.hubConnection.on(EVENTS.WordCountAnalyzerProgress, resp => {
this.messagesSource.next({
event: EVENTS.WordCountAnalyzerProgress,
payload: resp.body
});
});
this.hubConnection.on(EVENTS.LibraryModified, resp => {
this.messagesSource.next({
event: EVENTS.LibraryModified,

View file

@ -22,7 +22,7 @@ export class MetadataService {
private ageRatingTypes: {[key: number]: string} | undefined = undefined;
private validLanguages: Array<Language> = [];
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
constructor(private httpClient: HttpClient) { }
getAgeRating(ageRating: AgeRating) {
if (this.ageRatingTypes != undefined && this.ageRatingTypes.hasOwnProperty(ageRating)) {
@ -97,4 +97,8 @@ export class MetadataService {
}
return this.httpClient.get<Array<Person>>(this.baseUrl + method);
}
getChapterSummary(chapterId: number) {
return this.httpClient.get<string>(this.baseUrl + 'metadata/chapter-summary?chapterId=' + chapterId, {responseType: 'text' as 'json'});
}
}

View file

@ -4,10 +4,14 @@ import { environment } from 'src/environments/environment';
import { ChapterInfo } from '../manga-reader/_models/chapter-info';
import { UtilityService } from '../shared/_services/utility.service';
import { Chapter } from '../_models/chapter';
import { HourEstimateRange } from '../_models/hour-estimate-range';
import { MangaFormat } from '../_models/manga-format';
import { BookmarkInfo } from '../_models/manga-reader/bookmark-info';
import { PageBookmark } from '../_models/page-bookmark';
import { ProgressBookmark } from '../_models/progress-bookmark';
import { Volume } from '../_models/volume';
export const CHAPTER_ID_DOESNT_EXIST = -1;
export const CHAPTER_ID_NOT_FETCHED = -2;
@Injectable({
providedIn: 'root'
@ -21,6 +25,22 @@ export class ReaderService {
constructor(private httpClient: HttpClient, private utilityService: UtilityService) { }
getNavigationArray(libraryId: number, seriesId: number, chapterId: number, format: MangaFormat) {
if (format === undefined) format = MangaFormat.ARCHIVE;
if (format === MangaFormat.EPUB) {
return ['library', libraryId, 'series', seriesId, 'book', chapterId];
} else if (format === MangaFormat.PDF) {
return ['library', libraryId, 'series', seriesId, 'pdf', chapterId];
} else {
return ['library', libraryId, 'series', seriesId, 'manga', chapterId];
}
}
downloadPdf(chapterId: number) {
return this.baseUrl + 'reader/pdf?chapterId=' + chapterId;
}
bookmark(seriesId: number, volumeId: number, chapterId: number, page: number) {
return this.httpClient.post(this.baseUrl + 'reader/bookmark', {seriesId, volumeId, chapterId, page});
}
@ -124,6 +144,11 @@ export class ReaderService {
return this.httpClient.get<Chapter>(this.baseUrl + 'reader/continue-point?seriesId=' + seriesId);
}
// TODO: Cache this information
getTimeLeft(seriesId: number) {
return this.httpClient.get<HourEstimateRange>(this.baseUrl + 'reader/time-left?seriesId=' + seriesId);
}
/**
* Captures current body color and forces background color to be black. Call @see resetOverrideStyles() on destroy of component to revert changes
*/

View file

@ -22,6 +22,13 @@ export class RecommendationService {
.pipe(map(response => this.utilityService.createPaginatedResult(response)));
}
getQuickCatchupReads(libraryId: number, pageNum?: number, itemsPerPage?: number) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);
return this.httpClient.get<PaginatedResult<Series[]>>(this.baseUrl + 'recommended/quick-catchup-reads?libraryId=' + libraryId, {observe: 'response', params})
.pipe(map(response => this.utilityService.createPaginatedResult(response)));
}
getHighlyRated(libraryId: number, pageNum?: number, itemsPerPage?: number) {
let params = new HttpParams();
params = this.utilityService.addPaginationIfExists(params, pageNum, itemsPerPage);

View file

@ -3,16 +3,32 @@
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<!-- <div class="mb-3">
<label for="filter" class="form-label">Filter</label>
<div class="input-group">
<input id="filter" autocomplete="off" class="form-control" [(ngModel)]="filterQuery" type="text" aria-describedby="reset-input">
<button class="btn btn-outline-secondary" type="button" id="reset-input" (click)="filterQuery = '';">Clear</button>
</div>
</div> -->
<div class="mb-3">
<label for="filter" class="form-label">Path</label>
<div class="input-group">
<input id="typeahead-focus" type="text" class="form-control" [(ngModel)]="path" [ngbTypeahead]="search"
(focus)="focus$.next($any($event).target.value)" (click)="click$.next($any($event).target.value)"
(ngModelChange)="updateTable()" #instance="ngbTypeahead" placeholder="Start typing or select path"
[resultTemplate]="rt" />
</div>
<ng-template #rt let-r="result" let-t="term">
<ngb-highlight [result]="r" [term]="t"></ngb-highlight>
</ng-template>
</div>
<nav aria-label="directory breadcrumb">
<ol class="breadcrumb" *ngIf="routeStack.peek() !== undefined; else noBreadcrumb">
<li class="breadcrumb-item {{route === routeStack.peek() ? 'active' : ''}}" *ngFor="let route of routeStack.items; let index = index">
<li class="breadcrumb-item {{route === routeStack.peek() ? 'active' : ''}}"
*ngFor="let route of routeStack.items; let index = index">
<ng-container *ngIf="route === routeStack.peek(); else nonActive">
{{route}}
</ng-container>
@ -22,30 +38,34 @@
</li>
</ol>
<ng-template #noBreadcrumb>
<div class="breadcrumb">Select a folder to view breadcrumb. Don't see your directory, try checking / first.</div>
<div class="breadcrumb">Select a folder to view breadcrumb. Don't see your directory, try checking / first.
</div>
</ng-template>
</nav>
<ul class="list-group">
<div class="list-group-item list-group-item-action">
<button (click)="goBack()" class="btn btn-secondary" [disabled]="routeStack.peek() === undefined">
<i class="fa fa-arrow-left me-2" aria-hidden="true"></i>
Back
</button>
<button type="button" class="btn btn-primary float-end" [disabled]="routeStack.peek() === undefined" (click)="shareFolder('', $event)">Share</button>
</div>
</ul>
<ul class="list-group scrollable">
<button *ngFor="let folder of folders | filter: filterFolder" class="list-group-item list-group-item-action" (click)="selectNode(folder)">
<span>{{getStem(folder)}}</span>
<button type="button" class="btn btn-primary float-end" (click)="shareFolder(folder, $event)">Share</button>
</button>
<div class="list-group-item text-center" *ngIf="folders.length === 0">
There are no folders here
</div>
</ul>
<table class="table table-striped scrollable">
<thead>
<tr>
<th scope="col">Type</th>
<th scope="col">Name</th>
</tr>
</thead>
<tbody>
<tr (click)="goBack()">
<td><i class="fa-solid fa-arrow-turn-up" aria-hidden="true"></i></td>
<td>...</td>
</tr>
<tr *ngFor="let folder of folders; let idx = index;" (click)="selectNode(folder)">
<td><i class="fa-regular fa-folder" aria-hidden="true"></i></td>
<td id="folder--{{idx}}">
{{folder.name}}
</td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<a class="btn btn-icon" *ngIf="helpUrl.length > 0" href="{{helpUrl}}" target="_blank">Help</a>
<button type="button" class="btn btn-secondary" (click)="close()">Cancel</button>
<button type="button" class="btn btn-primary" (click)="share()">Share</button>
</div>

View file

@ -13,3 +13,7 @@ $breadcrumb-divider: quote(">");
.btn-outline-secondary {
border: 1px solid #ced4da;
}
.table {
background-color: lightgrey;
}

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