diff --git a/.gitignore b/.gitignore index aa7e9685f..4de30b256 100644 --- a/.gitignore +++ b/.gitignore @@ -515,10 +515,9 @@ UI/Web/dist/ /API/config/bookmarks/ /API/config/favicons/ /API/config/cache-long/ -/API/config/kavita.db -/API/config/kavita.db-shm -/API/config/kavita.db-wal -/API/config/kavita.db-journal +/API/config/*.db-shm +/API/config/*.db-wal +/API/config/*.db-journal /API/config/*.db /API/config/*.bak /API/config/*.backup diff --git a/API.Tests/AbstractDbTest.cs b/API.Tests/AbstractDbTest.cs index db3618928..c8786333e 100644 --- a/API.Tests/AbstractDbTest.cs +++ b/API.Tests/AbstractDbTest.cs @@ -16,39 +16,44 @@ using NSubstitute; using Xunit.Abstractions; namespace API.Tests; +#nullable enable -public abstract class AbstractDbTest(ITestOutputHelper testOutputHelper): AbstractFsTest +public abstract class AbstractDbTest(ITestOutputHelper testOutputHelper): AbstractFsTest, IAsyncDisposable { + private SqliteConnection? _connection; + private DataContext? _context; + protected async Task<(IUnitOfWork, DataContext, IMapper)> CreateDatabase() { + // Dispose any previous connection if CreateDatabase is called multiple times + if (_connection != null) + { + await _context!.DisposeAsync(); + await _connection.DisposeAsync(); + } + _connection = new SqliteConnection("Filename=:memory:"); + await _connection.OpenAsync(); + var contextOptions = new DbContextOptionsBuilder() - .UseSqlite(CreateInMemoryDatabase()) + .UseSqlite(_connection) .EnableSensitiveDataLogging() .Options; - var context = new DataContext(contextOptions); + _context = new DataContext(contextOptions); - await context.Database.EnsureCreatedAsync(); + await _context.Database.EnsureCreatedAsync(); - await SeedDb(context); + await SeedDb(_context); var config = new MapperConfiguration(cfg => cfg.AddProfile()); var mapper = config.CreateMapper(); GlobalConfiguration.Configuration.UseInMemoryStorage(); - var unitOfWork = new UnitOfWork(context, mapper, null); + var unitOfWork = new UnitOfWork(_context, mapper, null); - return (unitOfWork, context, mapper); - } - - private static SqliteConnection CreateInMemoryDatabase() - { - var connection = new SqliteConnection("Filename=:memory:"); - connection.Open(); - - return connection; + return (unitOfWork, _context, mapper); } private async Task SeedDb(DataContext context) @@ -96,7 +101,7 @@ public abstract class AbstractDbTest(ITestOutputHelper testOutputHelper): Abstra /// /// /// - protected async Task AddUserWithRole(DataContext context, int userId, string roleName) + protected static async Task AddUserWithRole(DataContext context, int userId, string roleName) { var role = new AppRole { Id = userId, Name = roleName, NormalizedName = roleName.ToUpper() }; @@ -106,4 +111,19 @@ public abstract class AbstractDbTest(ITestOutputHelper testOutputHelper): Abstra await context.SaveChangesAsync(); } + public async ValueTask DisposeAsync() + { + if (_context != null) + { + await _context.DisposeAsync(); + } + + if (_connection != null) + { + await _connection.DisposeAsync(); + } + + GC.SuppressFinalize(this); + } + } diff --git a/API.Tests/AbstractFsTest.cs b/API.Tests/AbstractFsTest.cs index 3341a3a7c..0c6a0e262 100644 --- a/API.Tests/AbstractFsTest.cs +++ b/API.Tests/AbstractFsTest.cs @@ -1,10 +1,9 @@ - - using System.IO; using System.IO.Abstractions.TestingHelpers; using API.Services.Tasks.Scanner.Parser; namespace API.Tests; +#nullable enable public abstract class AbstractFsTest { diff --git a/API.Tests/Entities/ComicInfoTests.cs b/API.Tests/Entities/ComicInfoTests.cs index 783248a3b..e43f4ee77 100644 --- a/API.Tests/Entities/ComicInfoTests.cs +++ b/API.Tests/Entities/ComicInfoTests.cs @@ -92,4 +92,26 @@ public class ComicInfoTests #endregion + + #region ASIN/ISBN/GTIN + + [Theory] + [InlineData("0-306-40615-2")] // ISBN-10 + [InlineData("978-0-306-40615-7")] // ISBN-13 + [InlineData("99921-58-10-7")] + [InlineData("85-359-0277-5")] + public void IsValid(string code) + { + // Note: ASIN's starting with "B0" are not able to be converted to ISBN + Assert.Equal(code, ComicInfo.ParseGtin(code)); + } + + [Theory] + [InlineData("001234567890")] + [InlineData("9504000059437 ")] + public void IsInvalid(string code) + { + Assert.Equal(string.Empty, ComicInfo.ParseGtin(code)); + } + #endregion } diff --git a/API.Tests/Extensions/SeriesFilterTests.cs b/API.Tests/Extensions/SeriesFilterTests.cs index c21d4ada5..afb0627f4 100644 --- a/API.Tests/Extensions/SeriesFilterTests.cs +++ b/API.Tests/Extensions/SeriesFilterTests.cs @@ -52,7 +52,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o .Build(); context.Users.Add(user); - context.Library.Add(library); await context.SaveChangesAsync(); @@ -60,7 +59,8 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o var readerService = new ReaderService(unitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For(), - Substitute.For(), Substitute.For()); + Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For()); // Select Partial and set pages read to 5 on first chapter var partialSeries = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(2); @@ -195,14 +195,14 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o .Build(); context.Users.Add(user); - context.Library.Add(library); await context.SaveChangesAsync(); var readerService = new ReaderService(unitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For(), - Substitute.For(), Substitute.For()); + Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For()); // Set progress to 99.99% (99/100 pages read) var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(1); @@ -255,7 +255,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o .Build(); context.Users.Add(user); - context.Library.Add(library); await context.SaveChangesAsync(); return user; @@ -397,7 +396,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o .Build(); context.Users.Add(user); - context.Library.Add(library); await context.SaveChangesAsync(); return user; @@ -547,7 +545,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o .Build(); context.Users.Add(user); - context.Library.Add(library); await context.SaveChangesAsync(); return user; @@ -673,7 +670,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o .Build(); context.Users.Add(user); - context.Library.Add(library); await context.SaveChangesAsync(); return user; @@ -849,7 +845,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o .Build(); context.Users.Add(user); - context.Library.Add(library); await context.SaveChangesAsync(); return user; @@ -982,7 +977,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o .Build(); context.Users.Add(user); - context.Library.Add(library); await context.SaveChangesAsync(); var ratingService = new RatingService(unitOfWork, Substitute.For(), Substitute.For>()); @@ -1164,7 +1158,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o .Build(); context.Users.Add(user); - context.Library.Add(library); await context.SaveChangesAsync(); return user; @@ -1305,7 +1298,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o .Build(); context.Users.Add(user); - context.Library.Add(library); await context.SaveChangesAsync(); return user; diff --git a/API.Tests/Parsing/MangaParsingTests.cs b/API.Tests/Parsing/MangaParsingTests.cs index 23c144b30..8a983e899 100644 --- a/API.Tests/Parsing/MangaParsingTests.cs +++ b/API.Tests/Parsing/MangaParsingTests.cs @@ -361,6 +361,7 @@ public class MangaParsingTests [InlineData("หนึ่งความคิด นิจนิรันดร์ บทที่ 112", "112")] [InlineData("Monster #8 Ch. 001", "1")] [InlineData("Monster Ch. 001 [MangaPlus] [Digital] [amit34521]", "1")] + [InlineData("Naruto v2.5", Parser.DefaultChapter)] public void ParseChaptersTest(string filename, string expected) { Assert.Equal(expected, Parser.ParseChapter(filename, LibraryType.Manga)); diff --git a/API.Tests/Services/BookmarkServiceTests.cs b/API.Tests/Services/BookmarkServiceTests.cs index d7efeed5a..0e486d844 100644 --- a/API.Tests/Services/BookmarkServiceTests.cs +++ b/API.Tests/Services/BookmarkServiceTests.cs @@ -93,30 +93,32 @@ Substitute.For()); series.Library = new LibraryBuilder("Test LIb").Build(); context.Series.Add(series); - - context.AppUser.Add(new AppUser() { - UserName = "Joe", - Bookmarks = new List() - { - new AppUserBookmark() - { - Page = 1, - ChapterId = 1, - FileName = $"1/1/0001.jpg", - SeriesId = 1, - VolumeId = 1 - } - } + UserName = "Joe" }); await context.SaveChangesAsync(); + // Now add the bookmark after we have valid IDs + var user = await unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Bookmarks); + user.Bookmarks.Add(new AppUserBookmark() + { + Page = 1, + ChapterId = 1, + FileName = $"1/1/0001.jpg", + SeriesId = 1, + VolumeId = 1, + AppUserId = 1 + }); + + await context.SaveChangesAsync(); var ds = new DirectoryService(Substitute.For>(), filesystem); var bookmarkService = Create(ds, unitOfWork); - var user = await unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Bookmarks); + + // Reload user to get the bookmark + user = await unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Bookmarks); var result = await bookmarkService.RemoveBookmarkPage(user, new BookmarkDto() { @@ -126,7 +128,6 @@ Substitute.For()); VolumeId = 1 }); - Assert.True(result); Assert.Empty(ds.GetFiles(BookmarkDirectory, searchOption:SearchOption.AllDirectories)); Assert.Null(await unitOfWork.UserRepository.GetBookmarkAsync(1)); @@ -136,8 +137,8 @@ Substitute.For()); #region DeleteBookmarkFiles - [Fact] - public async Task DeleteBookmarkFiles_ShouldDeleteOnlyPassedFiles() + [Fact] + public async Task DeleteBookmarkFiles_ShouldDeleteOnlyPassedFiles() { var (unitOfWork, context, _) = await CreateDatabase(); @@ -158,41 +159,44 @@ Substitute.For()); series.Library = new LibraryBuilder("Test LIb").Build(); context.Series.Add(series); - context.AppUser.Add(new AppUser() { - UserName = "Joe", - Bookmarks = new List() - { - new AppUserBookmark() - { - Page = 1, - ChapterId = 1, - FileName = $"1/1/1/0001.jpg", - SeriesId = 1, - VolumeId = 1 - }, - new AppUserBookmark() - { - Page = 2, - ChapterId = 1, - FileName = $"1/2/1/0002.jpg", - SeriesId = 2, - VolumeId = 1 - }, - new AppUserBookmark() - { - Page = 1, - ChapterId = 2, - FileName = $"1/2/1/0001.jpg", - SeriesId = 2, - VolumeId = 1 - } - } + UserName = "Joe" }); await context.SaveChangesAsync(); + // Add bookmarks after entities are saved + var user = await unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Bookmarks); + user.Bookmarks.Add(new AppUserBookmark() + { + Page = 1, + ChapterId = 1, + FileName = $"1/1/1/0001.jpg", + SeriesId = 1, + VolumeId = 1, + AppUserId = 1 + }); + user.Bookmarks.Add(new AppUserBookmark() + { + Page = 2, + ChapterId = 1, + FileName = $"1/2/1/0002.jpg", + SeriesId = 1, + VolumeId = 1, + AppUserId = 1 + }); + user.Bookmarks.Add(new AppUserBookmark() + { + Page = 3, + ChapterId = 1, + FileName = $"1/2/1/0001.jpg", + SeriesId = 1, + VolumeId = 1, + AppUserId = 1 + }); + + await context.SaveChangesAsync(); var ds = new DirectoryService(Substitute.For>(), filesystem); var bookmarkService = Create(ds, unitOfWork); @@ -200,15 +204,14 @@ Substitute.For()); await bookmarkService.DeleteBookmarkFiles([ new AppUserBookmark { - Page = 1, - ChapterId = 1, - FileName = $"1/1/1/0001.jpg", - SeriesId = 1, - VolumeId = 1 - } + Page = 1, + ChapterId = 1, + FileName = $"1/1/1/0001.jpg", + SeriesId = 1, + VolumeId = 1 + } ]); - Assert.Equal(2, ds.GetFiles(BookmarkDirectory, searchOption:SearchOption.AllDirectories).Count()); Assert.False(ds.FileSystem.FileInfo.New(Path.Join(BookmarkDirectory, "1/1/1/0001.jpg")).Exists); } @@ -265,112 +268,4 @@ Substitute.For()); #endregion - #region Misc - - [Fact] - public async Task ShouldNotDeleteBookmark_OnChapterDeletion() - { - var filesystem = CreateFileSystem(); - filesystem.AddFile($"{CacheDirectory}1/0001.jpg", new MockFileData("123")); - filesystem.AddFile($"{BookmarkDirectory}1/1/0001.jpg", new MockFileData("123")); - - var (unitOfWork, context, _) = await CreateDatabase(); - - var series = new SeriesBuilder("Test") - .WithFormat(MangaFormat.Epub) - .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("1") - .Build()) - .Build()) - .Build(); - series.Library = new LibraryBuilder("Test LIb").Build(); - - context.Series.Add(series); - - context.AppUser.Add(new AppUser() - { - UserName = "Joe", - Bookmarks = new List() - { - new AppUserBookmark() - { - Page = 1, - ChapterId = 1, - FileName = $"1/1/0001.jpg", - SeriesId = 1, - VolumeId = 1 - } - } - }); - - await context.SaveChangesAsync(); - - - var ds = new DirectoryService(Substitute.For>(), filesystem); - - var vol = await unitOfWork.VolumeRepository.GetVolumeAsync(1); - vol.Chapters = new List(); - unitOfWork.VolumeRepository.Update(vol); - await unitOfWork.CommitAsync(); - - - Assert.Single(ds.GetFiles(BookmarkDirectory, searchOption:SearchOption.AllDirectories)); - Assert.NotNull(await unitOfWork.UserRepository.GetBookmarkAsync(1)); - } - - - [Fact] - public async Task ShouldNotDeleteBookmark_OnVolumeDeletion() - { - var filesystem = CreateFileSystem(); - filesystem.AddFile($"{CacheDirectory}1/0001.jpg", new MockFileData("123")); - filesystem.AddFile($"{BookmarkDirectory}1/1/0001.jpg", new MockFileData("123")); - - var (unitOfWork, context, _) = await CreateDatabase(); - var series = new SeriesBuilder("Test") - .WithFormat(MangaFormat.Epub) - .WithVolume(new VolumeBuilder("1") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("1") - .Build()) - .Build()) - .Build(); - series.Library = new LibraryBuilder("Test LIb").Build(); - - context.Series.Add(series); - - - context.AppUser.Add(new AppUser() - { - UserName = "Joe", - Bookmarks = new List() - { - new AppUserBookmark() - { - Page = 1, - ChapterId = 1, - FileName = $"1/1/0001.jpg", - SeriesId = 1, - VolumeId = 1 - } - } - }); - - await context.SaveChangesAsync(); - - var user = await unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Bookmarks); - Assert.NotEmpty(user!.Bookmarks); - - series.Volumes = new List(); - unitOfWork.SeriesRepository.Update(series); - await unitOfWork.CommitAsync(); - - - var ds = new DirectoryService(Substitute.For>(), filesystem); - Assert.Single(ds.GetFiles(BookmarkDirectory, searchOption:SearchOption.AllDirectories)); - Assert.NotNull(await unitOfWork.UserRepository.GetBookmarkAsync(1)); - } - - #endregion } diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs index 6f80ce987..53fddd328 100644 --- a/API.Tests/Services/CleanupServiceTests.cs +++ b/API.Tests/Services/CleanupServiceTests.cs @@ -41,7 +41,8 @@ public class CleanupServiceTests(ITestOutputHelper outputHelper): AbstractDbTest var readerService = new ReaderService(unitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem()), - Substitute.For(), Substitute.For(), Substitute.For()); + Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For(), Substitute.For()); return Task.FromResult<(ILogger, IEventHub, IReaderService)>((logger, messageHub, readerService)); } diff --git a/API.Tests/Services/OidcServiceTests.cs b/API.Tests/Services/OidcServiceTests.cs index 535c67417..13dafd6d7 100644 --- a/API.Tests/Services/OidcServiceTests.cs +++ b/API.Tests/Services/OidcServiceTests.cs @@ -10,6 +10,7 @@ using API.Entities; using API.Entities.Enums; using API.Helpers.Builders; using API.Services; +using API.Services.Tasks.Metadata; using API.Services.Tasks.Scanner; using AutoMapper; using Kavita.Common; @@ -740,7 +741,7 @@ public class OidcServiceTests(ITestOutputHelper outputHelper): AbstractDbTest(ou var accountService = new AccountService(userManager, Substitute.For>(), unitOfWork, mapper, Substitute.For()); var oidcService = new OidcService(Substitute.For>(), userManager, unitOfWork, - accountService, Substitute.For()); + accountService, Substitute.For(), Substitute.For()); return (oidcService, user, accountService, userManager); } diff --git a/API.Tests/Services/OpdsServiceTests.cs b/API.Tests/Services/OpdsServiceTests.cs index 1c86b5758..b66450f9c 100644 --- a/API.Tests/Services/OpdsServiceTests.cs +++ b/API.Tests/Services/OpdsServiceTests.cs @@ -44,7 +44,7 @@ public class OpdsServiceTests(ITestOutputHelper testOutputHelper) : AbstractDbTe var readerService = new ReaderService(unitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), ds, Substitute.For(), Substitute.For(), - Substitute.For()); + Substitute.For(), Substitute.For(), Substitute.For()); var localizationService = new LocalizationService(ds, new MockHostingEnvironment(), Substitute.For(), unitOfWork); diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index da0c17066..2246860be 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -33,7 +33,8 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb return new ReaderService(unitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem()), - Substitute.For(), Substitute.For(), Substitute.For()); + Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For(), Substitute.For()); } #region FormatBookmarkFolderPath diff --git a/API.Tests/Services/ReadingListServiceTests.cs b/API.Tests/Services/ReadingListServiceTests.cs index 85da686dc..8f4c7d0af 100644 --- a/API.Tests/Services/ReadingListServiceTests.cs +++ b/API.Tests/Services/ReadingListServiceTests.cs @@ -35,7 +35,8 @@ public class ReadingListServiceTests(ITestOutputHelper outputHelper): AbstractDb var readerService = new ReaderService(unitOfWork, Substitute.For>(), Substitute.For(), Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem()), - Substitute.For(), Substitute.For(), Substitute.For()); + Substitute.For(), Substitute.For(), + Substitute.For(), Substitute.For(), Substitute.For()); return (readingListService, readerService); } diff --git a/API.Tests/Services/ScrobblingServiceTests.cs b/API.Tests/Services/ScrobblingServiceTests.cs index 4de852022..b4337dbcd 100644 --- a/API.Tests/Services/ScrobblingServiceTests.cs +++ b/API.Tests/Services/ScrobblingServiceTests.cs @@ -59,7 +59,7 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT Substitute.For(), Substitute.For(), Substitute.For(), Substitute.For(), - Substitute.For()); // Do not use the actual one + Substitute.For(), Substitute.For(), Substitute.For()); // Do not use the actual one var hookedUpReaderService = new ReaderService(unitOfWork, Substitute.For>(), @@ -67,7 +67,7 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT Substitute.For(), Substitute.For(), service, Substitute.For(), - Substitute.For()); + Substitute.For(), Substitute.For(), Substitute.For()); await SeedData(unitOfWork, context); @@ -124,11 +124,11 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT await unitOfWork.CommitAsync(); } - private async Task CreateScrobbleEvent(int? seriesId = null) + private async Task CreateScrobbleEvent(IUnitOfWork unitOfWork, int? seriesId = null) { - var (unitOfWork, context, _) = await CreateDatabase(); - await Setup(unitOfWork, context); - + // var (unitOfWork, context, _) = await CreateDatabase(); + // await Setup(unitOfWork, context); + // var evt = new ScrobbleEvent { @@ -163,7 +163,7 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT ErrorMessage = "Unauthorized" }); - var evt = await CreateScrobbleEvent(); + var evt = await CreateScrobbleEvent(unitOfWork); await Assert.ThrowsAsync(async () => { await service.PostScrobbleUpdate(new ScrobbleDto(), "", evt); @@ -184,9 +184,9 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT ErrorMessage = "Unknown Series" }); - var evt = await CreateScrobbleEvent(1); + var evt = await CreateScrobbleEvent(unitOfWork, 1); - await service.PostScrobbleUpdate(new ScrobbleDto(), "", evt); + await service.PostScrobbleUpdate(new ScrobbleDto(), string.Empty, evt); await unitOfWork.CommitAsync(); Assert.True(evt.IsErrored); @@ -212,7 +212,7 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT ErrorMessage = "Access token is invalid" }); - var evt = await CreateScrobbleEvent(); + var evt = await CreateScrobbleEvent(unitOfWork); await Assert.ThrowsAsync(async () => { @@ -249,7 +249,7 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(4); Assert.NotNull(chapter); - var volume = await unitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters); + var volume = await unitOfWork.VolumeRepository.GetVolumeByIdAsync(1, VolumeIncludes.Chapters); Assert.NotNull(volume); // Call Scrobble without having any progress @@ -280,7 +280,7 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(4); Assert.NotNull(chapter); - var volume = await unitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters); + var volume = await unitOfWork.VolumeRepository.GetVolumeByIdAsync(1, VolumeIncludes.Chapters); Assert.NotNull(volume); // Mark something as read to trigger event creation @@ -335,7 +335,7 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT var user = await unitOfWork.UserRepository.GetUserByIdAsync(1); Assert.NotNull(user); - var volume = await unitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters); + var volume = await unitOfWork.VolumeRepository.GetVolumeByIdAsync(1, VolumeIncludes.Chapters); Assert.NotNull(volume); await readerService.MarkChaptersAsRead(user, 1, new List() {volume.Chapters[0]}); diff --git a/API.Tests/Services/TachiyomiServiceTests.cs b/API.Tests/Services/TachiyomiServiceTests.cs index 6b1bf9acd..5c21ed0bc 100644 --- a/API.Tests/Services/TachiyomiServiceTests.cs +++ b/API.Tests/Services/TachiyomiServiceTests.cs @@ -28,7 +28,7 @@ public class TachiyomiServiceTests(ITestOutputHelper outputHelper): AbstractDbTe Substitute.For(), Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem()), Substitute.For(), Substitute.For(), - Substitute.For()); + Substitute.For(), Substitute.For(), Substitute.For()); var tachiyomiService = new TachiyomiService(unitOfWork, mapper, Substitute.For>(), readerService); return (readerService, tachiyomiService); diff --git a/API.Tests/Services/WordCountAnalysisTests.cs b/API.Tests/Services/WordCountAnalysisTests.cs index 4e6445c57..264571a61 100644 --- a/API.Tests/Services/WordCountAnalysisTests.cs +++ b/API.Tests/Services/WordCountAnalysisTests.cs @@ -34,7 +34,7 @@ public class WordCountAnalysisTests(ITestOutputHelper outputHelper): AbstractDbT Substitute.For(), Substitute.For(), new DirectoryService(Substitute.For>(), new MockFileSystem()), Substitute.For(), Substitute.For(), - Substitute.For()); + Substitute.For(), Substitute.For(), Substitute.For()); } [Fact] diff --git a/API/API.csproj b/API/API.csproj index db267549d..aca7b7b02 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -52,6 +52,8 @@ + + diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index da108b813..38f6af318 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -20,6 +20,7 @@ using API.Helpers; using API.Helpers.Builders; using API.Middleware; using API.Services; +using API.Services.Caching; using API.SignalR; using AutoMapper; using Hangfire; @@ -56,6 +57,7 @@ public class AccountController : BaseApiController private readonly IEventHub _eventHub; private readonly ILocalizationService _localizationService; private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider; + private readonly IAuthKeyCacheInvalidator _authKeyCacheInvalidator; /// public AccountController(UserManager userManager, @@ -65,7 +67,8 @@ public class AccountController : BaseApiController IMapper mapper, IAccountService accountService, IEmailService emailService, IEventHub eventHub, ILocalizationService localizationService, - IAuthenticationSchemeProvider authenticationSchemeProvider) + IAuthenticationSchemeProvider authenticationSchemeProvider, + IAuthKeyCacheInvalidator authKeyCacheInvalidator) { _userManager = userManager; _signInManager = signInManager; @@ -78,6 +81,7 @@ public class AccountController : BaseApiController _eventHub = eventHub; _localizationService = localizationService; _authenticationSchemeProvider = authenticationSchemeProvider; + _authKeyCacheInvalidator = authKeyCacheInvalidator; } /// @@ -717,6 +721,10 @@ public class AccountController : BaseApiController { roles.Add(PolicyConstants.PlebRole); } + else + { + roles.Remove(PolicyConstants.ReadOnlyRole); + } foreach (var role in roles) { @@ -1190,17 +1198,26 @@ public class AccountController : BaseApiController var authKey = await _unitOfWork.UserRepository.GetAuthKeyById(authKeyId); if (authKey?.AppUserId != UserId) return BadRequest(); + var oldKeyValue = authKey.Key; + // Get original expiresAt - createdAt for offset to reset expiresAt if (authKey.ExpiresAtUtc != null) { var originalDuration = authKey.ExpiresAtUtc.Value - authKey.CreatedAtUtc; authKey.ExpiresAtUtc = DateTime.UtcNow.Add(originalDuration); } + authKey.Key = AuthKeyHelper.GenerateKey(dto.KeyLength); await _unitOfWork.CommitAsync(); - return Ok(_mapper.Map(authKey)); + await _authKeyCacheInvalidator.InvalidateAsync(oldKeyValue); + + var newDto = _mapper.Map(authKey); + + await _eventHub.SendMessageToAsync(MessageFactory.AuthKeyUpdate, MessageFactory.AuthKeyUpdatedEvent(newDto), UserId); + + return Ok(newDto); } /// @@ -1212,12 +1229,6 @@ public class AccountController : BaseApiController [DisallowRole(PolicyConstants.ReadOnlyRole)] public async Task> CreateAuthKey(RotateAuthKeyRequestDto dto) { - // Upper bound check might not be needed, it doesn't *realy* matter if users have bigger keys - if (string.IsNullOrEmpty(dto.Name) || dto.KeyLength < 8 || dto.KeyLength > 32) - { - return BadRequest(); - } - // Validate the name doesn't collide var authKeys = await _unitOfWork.UserRepository.GetAuthKeysForUserId(UserId); if (authKeys.Any(k => string.Equals(k.Name, dto.Name, StringComparison.InvariantCultureIgnoreCase))) @@ -1237,7 +1248,11 @@ public class AccountController : BaseApiController _unitOfWork.UserRepository.Add(newKey); await _unitOfWork.CommitAsync(); - return Ok(_mapper.Map(newKey)); + var newDto = _mapper.Map(newKey); + + await _eventHub.SendMessageToAsync(MessageFactory.AuthKeyUpdate, MessageFactory.AuthKeyUpdatedEvent(newDto), UserId); + + return Ok(newDto); } /// @@ -1255,6 +1270,9 @@ public class AccountController : BaseApiController _unitOfWork.UserRepository.Delete(authKey); await _unitOfWork.CommitAsync(); + + await _eventHub.SendMessageToAsync(MessageFactory.AuthKeyUpdate, MessageFactory.AuthKeyDeletedEvent(authKeyId), UserId); + return Ok(); } } diff --git a/API/Controllers/BaseApiController.cs b/API/Controllers/BaseApiController.cs index 30efb073f..7b0dcce81 100644 --- a/API/Controllers/BaseApiController.cs +++ b/API/Controllers/BaseApiController.cs @@ -1,4 +1,6 @@ -using API.Services.Store; +using API.Middleware; +using API.Services.Store; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; @@ -7,18 +9,16 @@ namespace API.Controllers; #nullable enable +[Authorize] [ApiController] [Route("api/[controller]")] -[Authorize] public class BaseApiController : ControllerBase { - private IUserContext? _userContext; - /// /// Gets the current user context. Available in all derived controllers. /// protected IUserContext UserContext => - _userContext ??= HttpContext.RequestServices.GetRequiredService(); + field ??= HttpContext.RequestServices.GetRequiredService(); /// /// Gets the current authenticated user's ID. @@ -31,4 +31,5 @@ public class BaseApiController : ControllerBase /// /// Warning! Username's can contain .. and /, do not use folders or filenames explicitly with the Username protected string? Username => UserContext.GetUsername(); + } diff --git a/API/Controllers/ChapterController.cs b/API/Controllers/ChapterController.cs index 0ab4389a6..081df91b9 100644 --- a/API/Controllers/ChapterController.cs +++ b/API/Controllers/ChapterController.cs @@ -67,7 +67,7 @@ public class ChapterController : BaseApiController if (chapter == null) return BadRequest(_localizationService.Translate(UserId, "chapter-doesnt-exist")); - var vol = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId, VolumeIncludes.Chapters); + var vol = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(chapter.VolumeId, VolumeIncludes.Chapters); if (vol == null) return BadRequest(_localizationService.Translate(UserId, "volume-doesnt-exist")); // If there is only 1 chapter within the volume, then we need to remove the volume @@ -139,7 +139,7 @@ public class ChapterController : BaseApiController var chaptersToDelete = volumeGroup.ToList(); // Fetch the volume - var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters); + var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(volumeId, VolumeIncludes.Chapters); if (volume == null) return BadRequest(_localizationService.Translate(UserId, "volume-doesnt-exist")); diff --git a/API/Controllers/DeprecatedController.cs b/API/Controllers/DeprecatedController.cs new file mode 100644 index 000000000..1c009d5fd --- /dev/null +++ b/API/Controllers/DeprecatedController.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Constants; +using API.Data; +using API.DTOs; +using API.DTOs.Filtering; +using API.DTOs.Metadata; +using API.DTOs.Uploads; +using API.Entities; +using API.Extensions; +using API.Helpers; +using API.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using TaskScheduler = API.Services.TaskScheduler; + +namespace API.Controllers; + +/// +/// All APIs here are subject to be removed and are no longer maintained +/// +[Route("api/")] +public class DeprecatedController : BaseApiController +{ + private readonly IUnitOfWork _unitOfWork; + private readonly ILocalizationService _localizationService; + private readonly ITaskScheduler _taskScheduler; + private readonly ILogger _logger; + + public DeprecatedController(IUnitOfWork unitOfWork, ILocalizationService localizationService, ITaskScheduler taskScheduler, ILogger logger) + { + _unitOfWork = unitOfWork; + _localizationService = localizationService; + _taskScheduler = taskScheduler; + _logger = logger; + } + + /// + /// Return all Series that are in the current logged-in user's Want to Read list, filtered (deprecated, use v2) + /// + /// This will be removed in v0.9.0 + /// + /// + /// + [HttpPost("want-to-read")] + [Obsolete("use v2 instead. This will be removed in v0.9.0")] + public async Task>> GetWantToRead([FromQuery] UserParams? userParams, FilterDto filterDto) + { + userParams ??= new UserParams(); + var pagedList = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(UserId, userParams, filterDto); + Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); + + await _unitOfWork.SeriesRepository.AddSeriesModifiers(UserId, pagedList); + + return Ok(pagedList); + } + + /// + /// All chapter entities will load this data by default. Will not be maintained as of v0.8.1 + /// + /// + /// + [Obsolete("All chapter entities will load this data by default. Will be removed in v0.9.0")] + [HttpGet("series/chapter-metadata")] + public async Task> GetChapterMetadata(int chapterId) + { + return Ok(await _unitOfWork.ChapterRepository.GetChapterMetadataDtoAsync(chapterId)); + } + + /// + /// Gets series with the applied Filter + /// + /// This is considered v1 and no longer used by Kavita, but will be supported for sometime. See series/v2 + /// + /// + /// + /// + [HttpPost("series")] + [Obsolete("use v2. Will be removed in v0.9.0")] + public async Task>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto) + { + var userId = UserId; + var series = + await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto); + + // Apply progress/rating information (I can't work out how to do this in initial query) + if (series == null) return BadRequest(await _localizationService.Translate(UserId, "no-series")); + + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); + + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + + return Ok(series); + } + + /// + /// Gets all recently added series. Obsolete, use recently-added-v2 + /// + /// + /// + /// + /// + [ResponseCache(CacheProfileName = "Instant")] + [HttpPost("series/recently-added")] + [Obsolete("use recently-added-v2. Will be removed in v0.9.0")] + public async Task>> GetRecentlyAdded(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) + { + var userId = UserId; + var series = + await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, userId, userParams, filterDto); + + // Apply progress/rating information (I can't work out how to do this in initial query) + if (series == null) return BadRequest(await _localizationService.Translate(UserId, "no-series")); + + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); + + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + + return Ok(series); + } + + /// + /// Returns all series for the library. Obsolete, use all-v2 + /// + /// + /// + /// + /// + [HttpPost("series/all")] + [Obsolete("Use all-v2. Will be removed in v0.9.0")] + public async Task>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) + { + var userId = UserId; + var series = + await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto); + + // Apply progress/rating information (I can't work out how to do this in initial query) + if (series == null) return BadRequest(await _localizationService.Translate(UserId, "no-series")); + + await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); + + Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); + + return Ok(series); + } + + /// + /// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image. + /// + /// Does not use Url property + /// + [Authorize(Policy = PolicyGroups.AdminPolicy)] + [HttpPost("upload/reset-chapter-lock")] + [Obsolete("Use LockCover in UploadFileDto, will be removed in v0.9.0")] + public async Task ResetChapterLock(UploadFileDto uploadFileDto) + { + try + { + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); + if (chapter == null) return BadRequest(await _localizationService.Translate(UserId, "chapter-doesnt-exist")); + var originalFile = chapter.CoverImage; + + chapter.CoverImage = string.Empty; + chapter.CoverImageLocked = false; + _unitOfWork.ChapterRepository.Update(chapter); + + var volume = (await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(chapter.VolumeId))!; + volume.CoverImage = chapter.CoverImage; + _unitOfWork.VolumeRepository.Update(volume); + + var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId))!; + + if (_unitOfWork.HasChanges()) + { + await _unitOfWork.CommitAsync(); + if (originalFile != null) System.IO.File.Delete(originalFile); + await _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true); + return Ok(); + } + + } + catch (Exception e) + { + _logger.LogError(e, "There was an issue resetting cover lock for Chapter {Id}", uploadFileDto.Id); + await _unitOfWork.RollbackAsync(); + } + + return BadRequest(await _localizationService.Translate(UserId, "reset-chapter-lock")); + } + + +} diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs index 4232fab4d..144694938 100644 --- a/API/Controllers/MetadataController.cs +++ b/API/Controllers/MetadataController.cs @@ -23,9 +23,7 @@ namespace API.Controllers; #nullable enable -public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService localizationService, - IExternalMetadataService metadataService) - : BaseApiController +public class MetadataController(IUnitOfWork unitOfWork, IExternalMetadataService metadataService) : BaseApiController { public const string CacheKey = "kavitaPlusSeriesDetail_"; diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 457e9eb05..772c99ade 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -21,7 +21,7 @@ using MimeTypes; namespace API.Controllers; #nullable enable -[AllowAnonymous] +[Authorize] public class OpdsController : BaseApiController { private readonly IOpdsService _opdsService; diff --git a/API/Controllers/OidcController.cs b/API/Controllers/OidcController.cs index 69b6ae0b4..87c3637bb 100644 --- a/API/Controllers/OidcController.cs +++ b/API/Controllers/OidcController.cs @@ -1,6 +1,7 @@ #nullable enable using System.Threading.Tasks; using API.Extensions; +using API.Middleware; using API.Services; using Kavita.Common; using Microsoft.AspNetCore.Authentication; @@ -16,6 +17,7 @@ namespace API.Controllers; [Route("[controller]")] public class OidcController(ILogger logger, [FromServices] ConfigurationManager? configurationManager = null): ControllerBase { + [SkipDeviceTracking] [AllowAnonymous] [HttpGet("login")] public IActionResult Login(string returnUrl = "/") @@ -29,6 +31,7 @@ public class OidcController(ILogger logger, [FromServices] Confi return Challenge(properties, IdentityServiceExtensions.OpenIdConnect); } + [SkipDeviceTracking] [HttpGet("logout")] public async Task Logout() { diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs index bd9a5c9d1..9f4638a7c 100644 --- a/API/Controllers/PersonController.cs +++ b/API/Controllers/PersonController.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using System.Threading.Tasks; using API.Constants; using API.Data; +using API.Data.Metadata; using API.Data.Repositories; using API.DTOs; using API.DTOs.Metadata.Browse; @@ -14,6 +16,7 @@ using API.Helpers; using API.Services; using API.Services.Plus; using API.Services.Tasks.Metadata; +using API.Services.Tasks.Scanner.Parser; using API.SignalR; using AutoMapper; using Microsoft.AspNetCore.Authorization; @@ -177,8 +180,7 @@ public class PersonController : BaseApiController } var asin = dto.Asin?.Trim(); - if (!string.IsNullOrEmpty(asin) && - (ArticleNumberHelper.IsValidIsbn10(asin) || ArticleNumberHelper.IsValidIsbn13(asin))) + if (!string.IsNullOrEmpty(asin) && Parser.IsLikelyValidAsin(asin)) { person.Asin = asin; } @@ -189,18 +191,6 @@ public class PersonController : BaseApiController return Ok(_mapper.Map(person)); } - /// - /// Validates if the ASIN (10/13) is valid - /// - /// - /// - [HttpGet("valid-asin")] - public ActionResult ValidateAsin(string asin) - { - return Ok(!string.IsNullOrEmpty(asin) && - (ArticleNumberHelper.IsValidIsbn10(asin) || ArticleNumberHelper.IsValidIsbn13(asin))); - } - /// /// Attempts to download the cover from CoversDB (Note: Not yet release in Kavita) /// diff --git a/API/Controllers/PluginController.cs b/API/Controllers/PluginController.cs index a19e328c3..7f10b8016 100644 --- a/API/Controllers/PluginController.cs +++ b/API/Controllers/PluginController.cs @@ -74,4 +74,15 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService if (userId <= 0) throw new KavitaUnauthenticatedUserException(); return Ok((await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value); } + + /// + /// Returns the expiration (UTC) of the authenticated Auth key (or null if none set) + /// + /// Will always return null if the Auth Key does not belong to this account + /// + [HttpGet("authkey-expires")] + public async Task> GetAuthKeyExpiration([Required] string authKey) + { + return Ok(await unitOfWork.UserRepository.GetAuthKeyExpiration(authKey, UserId)); + } } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index e38751dd4..ab63a03fb 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -1023,4 +1023,41 @@ public class ReaderController : BaseApiController } + /// + /// Check if we should prompt the user for rereads for the given series + /// + /// + /// + [HttpGet("prompt-reread/series")] + public async Task> ShouldPromptForSeriesReRead(int seriesId) + { + return Ok(await _readerService.CheckSeriesForReRead(UserId, seriesId)); + } + + /// + /// Check if we should prompt the user for rereads for the given volume + /// + /// + /// + /// + /// + [HttpGet("prompt-reread/volume")] + public async Task> ShouldPromptForVolumeReRead(int libraryId, int seriesId, int volumeId) + { + return Ok(await _readerService.CheckVolumeForReRead(UserId, volumeId, seriesId, libraryId)); + } + + /// + /// Check if we should prompt the user for rereads for the given chapter + /// + /// + /// + /// + /// + [HttpGet("prompt-reread/chapter")] + public async Task> ShouldPromptForChapterReRead(int libraryId, int seriesId, int chapterId) + { + return Ok(await _readerService.CheckChapterForReRead(UserId, chapterId, seriesId, libraryId)); + } + } diff --git a/API/Controllers/SearchController.cs b/API/Controllers/SearchController.cs index 10ce8fba2..ef1555909 100644 --- a/API/Controllers/SearchController.cs +++ b/API/Controllers/SearchController.cs @@ -1,5 +1,6 @@ using System.Linq; using System.Threading.Tasks; +using API.Constants; using API.Data; using API.Data.Repositories; using API.DTOs; @@ -60,15 +61,12 @@ public class SearchController : BaseApiController { queryString = Services.Tasks.Scanner.Parser.Parser.CleanQuery(queryString); - var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(Username!); - if (user == null) return Unauthorized(); - - var libraries = await _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search); + var libraries = await _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(UserId, QueryContext.Search); if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(UserId, "libraries-restricted")); - var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); + var isAdmin = UserContext.HasRole(PolicyConstants.AdminRole); - var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, + var series = await _unitOfWork.SeriesRepository.SearchSeries(UserId, isAdmin, libraries, queryString, includeChapterAndFiles); return Ok(series); diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index be64bb791..fd46eec50 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -68,32 +68,6 @@ public class SeriesController : BaseApiController _matchSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusMatchSeries); } - /// - /// Gets series with the applied Filter - /// - /// This is considered v1 and no longer used by Kavita, but will be supported for sometime. See series/v2 - /// - /// - /// - /// - [HttpPost] - [Obsolete("use v2")] - public async Task>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto) - { - var userId = UserId; - var series = - await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto); - - // Apply progress/rating information (I can't work out how to do this in initial query) - if (series == null) return BadRequest(await _localizationService.Translate(UserId, "no-series")); - - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); - - Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); - - return Ok(series); - } - /// /// Gets series with the applied Filter /// @@ -183,19 +157,9 @@ public class SeriesController : BaseApiController { var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, UserId); if (chapter == null) return NoContent(); - return Ok(await _unitOfWork.ChapterRepository.AddChapterModifiers(UserId, chapter)); - } + await _unitOfWork.ChapterRepository.AddChapterModifiers(UserId, chapter); - /// - /// All chapter entities will load this data by default. Will not be maintained as of v0.8.1 - /// - /// - /// - [Obsolete("All chapter entities will load this data by default. Will not be maintained as of v0.8.1")] - [HttpGet("chapter-metadata")] - public async Task> GetChapterMetadata(int chapterId) - { - return Ok(await _unitOfWork.ChapterRepository.GetChapterMetadataDtoAsync(chapterId)); + return Ok(chapter); } /// @@ -252,32 +216,6 @@ public class SeriesController : BaseApiController return Ok(); } - /// - /// Gets all recently added series. Obsolete, use recently-added-v2 - /// - /// - /// - /// - /// - [ResponseCache(CacheProfileName = "Instant")] - [HttpPost("recently-added")] - [Obsolete("use recently-added-v2")] - public async Task>> GetRecentlyAdded(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) - { - var userId = UserId; - var series = - await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, userId, userParams, filterDto); - - // Apply progress/rating information (I can't work out how to do this in initial query) - if (series == null) return BadRequest(await _localizationService.Translate(UserId, "no-series")); - - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); - - Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); - - return Ok(series); - } - /// /// Gets all recently added series /// @@ -338,30 +276,6 @@ public class SeriesController : BaseApiController return Ok(series); } - /// - /// Returns all series for the library. Obsolete, use all-v2 - /// - /// - /// - /// - /// - [HttpPost("all")] - [Obsolete("Use all-v2")] - public async Task>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0) - { - var userId = UserId; - var series = - await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto); - - // Apply progress/rating information (I can't work out how to do this in initial query) - if (series == null) return BadRequest(await _localizationService.Translate(UserId, "no-series")); - - await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series); - - Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages); - - return Ok(series); - } /// /// Fetches series that are on deck aka have progress on them. diff --git a/API/Controllers/StatsController.cs b/API/Controllers/StatsController.cs index 258c0ee78..3c8a394a8 100644 --- a/API/Controllers/StatsController.cs +++ b/API/Controllers/StatsController.cs @@ -35,16 +35,7 @@ public class StatsController( IDirectoryService directoryService) : BaseApiController { - [HttpGet("user/{userId}/read")] - [ResponseCache(CacheProfileName = ResponseCacheProfiles.Statistics)] - public async Task> GetUserReadStatistics(int userId) - { - var user = await unitOfWork.UserRepository.GetUserByUsernameAsync(Username!); - if (user!.Id != userId && !await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole)) - return Unauthorized(await localizationService.Translate(UserId, "stats-permission-denied")); - return Ok(await statService.GetUserReadStatistics(userId, new List())); - } [Authorize(PolicyGroups.AdminPolicy)] [HttpGet("server/stats")] @@ -404,6 +395,14 @@ public class StatsController( return Ok(await statService.GetUserStatBar(filter, userId, UserId)); } + [ProfilePrivacy] + [HttpGet("user-read")] + [ResponseCache(CacheProfileName = ResponseCacheProfiles.Statistics)] + public async Task> GetUserReadStatistics(int userId) + { + return Ok(await statService.GetUserReadStatistics(userId, [])); + } + // TODO: Can we cache this? Can we make an attribute to cache methods based on keys? /// /// Cleans the stats filter to only include valid data. I.e. only requests libraries the user has access to diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index f870ee0bb..7aabc5d8c 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -286,7 +286,7 @@ public class UploadController : BaseApiController chapter.CoverImageLocked = lockState; chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterCovers); _unitOfWork.ChapterRepository.Update(chapter); - var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId); + var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(chapter.VolumeId); if (volume != null) { volume.CoverImage = chapter.CoverImage; @@ -338,7 +338,7 @@ public class UploadController : BaseApiController // See if we can do this all in memory without touching underlying system try { - var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(uploadFileDto.Id, VolumeIncludes.Chapters); + var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(uploadFileDto.Id, VolumeIncludes.Chapters); if (volume == null) return BadRequest(await _localizationService.Translate(UserId, "volume-doesnt-exist")); var filePath = string.Empty; @@ -444,49 +444,6 @@ public class UploadController : BaseApiController return BadRequest(await _localizationService.Translate(UserId, "generic-cover-library-save")); } - /// - /// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image. - /// - /// Does not use Url property - /// - [Authorize(Policy = PolicyGroups.AdminPolicy)] - [HttpPost("reset-chapter-lock")] - [Obsolete("Use LockCover in UploadFileDto, will be removed in v0.9.0")] - public async Task ResetChapterLock(UploadFileDto uploadFileDto) - { - try - { - var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); - if (chapter == null) return BadRequest(await _localizationService.Translate(UserId, "chapter-doesnt-exist")); - var originalFile = chapter.CoverImage; - - chapter.CoverImage = string.Empty; - chapter.CoverImageLocked = false; - _unitOfWork.ChapterRepository.Update(chapter); - - var volume = (await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId))!; - volume.CoverImage = chapter.CoverImage; - _unitOfWork.VolumeRepository.Update(volume); - - var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId))!; - - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - if (originalFile != null) System.IO.File.Delete(originalFile); - await _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true); - return Ok(); - } - - } - catch (Exception e) - { - _logger.LogError(e, "There was an issue resetting cover lock for Chapter {Id}", uploadFileDto.Id); - await _unitOfWork.RollbackAsync(); - } - - return BadRequest(await _localizationService.Translate(UserId, "reset-chapter-lock")); - } /// /// Replaces person tag cover image and locks it with a base64 encoded image @@ -522,9 +479,9 @@ public class UploadController : BaseApiController /// You MUST be the user in question /// /// - [Authorize(Policy = PolicyGroups.AdminPolicy)] - [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] [HttpPost("user")] + [DisallowRole(PolicyConstants.ReadOnlyRole)] + [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] public async Task UploadUserCoverImageFromUrl(UploadFileDto uploadFileDto) { try diff --git a/API/Controllers/VolumeController.cs b/API/Controllers/VolumeController.cs index 428339f12..11d3d24ab 100644 --- a/API/Controllers/VolumeController.cs +++ b/API/Controllers/VolumeController.cs @@ -39,7 +39,7 @@ public class VolumeController : BaseApiController [HttpDelete] public async Task> DeleteVolume(int volumeId) { - var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, + var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(volumeId, VolumeIncludes.Chapters | VolumeIncludes.People | VolumeIncludes.Tags); if (volume == null) return BadRequest(_localizationService.Translate(UserId, "volume-doesnt-exist")); diff --git a/API/Controllers/WantToReadController.cs b/API/Controllers/WantToReadController.cs index 307becd3e..f5b4035c3 100644 --- a/API/Controllers/WantToReadController.cs +++ b/API/Controllers/WantToReadController.cs @@ -38,26 +38,6 @@ public class WantToReadController : BaseApiController _localizationService = localizationService; } - /// - /// Return all Series that are in the current logged-in user's Want to Read list, filtered (deprecated, use v2) - /// - /// This will be removed in v0.9.0 - /// - /// - /// - [HttpPost] - [Obsolete("use v2 instead. This will be removed in v0.9.0")] - public async Task>> GetWantToRead([FromQuery] UserParams? userParams, FilterDto filterDto) - { - userParams ??= new UserParams(); - var pagedList = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(UserId, userParams, filterDto); - Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages); - - await _unitOfWork.SeriesRepository.AddSeriesModifiers(UserId, pagedList); - - return Ok(pagedList); - } - /// /// Return all Series that are in the current logged in user's Want to Read list, filtered /// diff --git a/API/DTOs/Account/RotateAuthKeyRequestDto.cs b/API/DTOs/Account/RotateAuthKeyRequestDto.cs index 44a67eaec..ac7781dba 100644 --- a/API/DTOs/Account/RotateAuthKeyRequestDto.cs +++ b/API/DTOs/Account/RotateAuthKeyRequestDto.cs @@ -1,6 +1,4 @@ -using System; -using System.ComponentModel.DataAnnotations; -using API.Helpers; +using System.ComponentModel.DataAnnotations; namespace API.DTOs.Account; #nullable enable @@ -8,8 +6,10 @@ namespace API.DTOs.Account; public sealed record RotateAuthKeyRequestDto { [Required] + [Range(8, 32)] public int KeyLength { get; set; } + [Required(AllowEmptyStrings = false)] public required string Name { get; set; } public string? ExpiresUtc { get; set; } } diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index 9450f1bf9..98ef6c01e 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using API.DTOs.Metadata; using API.DTOs.Person; using API.Entities.Enums; @@ -173,6 +174,8 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage /// public string? SecondaryColor { get; set; } = string.Empty; + public MangaFormat? Format => Files.FirstOrDefault()?.Format; + public void ResetColorScape() { PrimaryColor = string.Empty; diff --git a/API/DTOs/Reader/ReReadDto.cs b/API/DTOs/Reader/ReReadDto.cs new file mode 100644 index 000000000..31b40315f --- /dev/null +++ b/API/DTOs/Reader/ReReadDto.cs @@ -0,0 +1,37 @@ +using API.Entities.Enums; + +namespace API.DTOs.Reader; + +public sealed record RereadDto +{ + /// + /// Should the prompt be shown + /// + public required bool ShouldPrompt { get; init; } + /// + /// If the prompt is triggered because of time, false when triggered because of fully read + /// + public bool TimePrompt { get; init; } = false; + /// + /// Days elapsed since was last read + /// + public int DaysSinceLastRead { get; init; } + /// + /// The chapter to open if continue is selected + /// + public RereadChapterDto ChapterOnContinue { get; init; } + /// + /// The chapter to open if reread is selected, this may be equal to + /// + public RereadChapterDto ChapterOnReread { get; init; } + + public static RereadDto Dont() + { + return new RereadDto + { + ShouldPrompt = false + }; + } +} + +public sealed record RereadChapterDto(int LibraryId, int SeriesId, int ChapterId, string Label, MangaFormat? Format); diff --git a/API/DTOs/Statistics/UserReadStatistics.cs b/API/DTOs/Statistics/UserReadStatistics.cs index 5c6935c6e..4b4dee9cd 100644 --- a/API/DTOs/Statistics/UserReadStatistics.cs +++ b/API/DTOs/Statistics/UserReadStatistics.cs @@ -19,7 +19,10 @@ public sealed record UserReadStatistics /// public long TimeSpentReading { get; set; } public long ChaptersRead { get; set; } - public DateTime LastActive { get; set; } + /// + /// Last time user read anything + /// + public DateTime? LastActiveUtc { get; set; } public double AvgHoursPerWeekSpentReading { get; set; } public IEnumerable>? PercentReadPerLibrary { get; set; } diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs index 85d94cd08..8fe2ce912 100644 --- a/API/Data/DataContext.cs +++ b/API/Data/DataContext.cs @@ -103,18 +103,6 @@ public sealed class DataContext : IdentityDbContext() - .HasMany(ur => ur.UserRoles) - .WithOne(u => u.User) - .HasForeignKey(ur => ur.UserId) - .IsRequired(); - - builder.Entity() - .HasMany(ur => ur.UserRoles) - .WithOne(u => u.Role) - .HasForeignKey(ur => ur.RoleId) - .IsRequired(); - builder.Entity() .HasOne(pt => pt.Series) .WithMany(p => p.Relations) @@ -130,6 +118,79 @@ public sealed class DataContext : IdentityDbContext() + .HasOne(em => em.Series) + .WithOne(s => s.ExternalSeriesMetadata) + .HasForeignKey(em => em.SeriesId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .Property(b => b.AgeRating) + .HasDefaultValue(AgeRating.Unknown); + + #region Library + + builder.Entity() + .Property(b => b.AllowScrobbling) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.AllowMetadataMatching) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.EnableMetadata) + .HasDefaultValue(true); + builder.Entity() + .Property(l => l.DefaultLanguage) + .HasDefaultValue(string.Empty); + + #endregion + + #region Chapter + builder.Entity() + .Property(b => b.WebLinks) + .HasDefaultValue(string.Empty); + + + builder.Entity() + .Property(b => b.ISBN) + .HasDefaultValue(string.Empty); + + // Configure the many-to-many relationship for Chapter and Person + builder.Entity() + .HasKey(cp => new { cp.ChapterId, cp.PersonId, cp.Role }); + + builder.Entity() + .HasOne(cp => cp.Chapter) + .WithMany(c => c.People) + .HasForeignKey(cp => cp.ChapterId); + + builder.Entity() + .HasOne(cp => cp.Person) + .WithMany(p => p.ChapterPeople) + .HasForeignKey(cp => cp.PersonId) + .OnDelete(DeleteBehavior.Cascade); + + + builder.Entity() + .Property(sm => sm.KPlusOverrides) + .HasJsonConversion([]) + .HasColumnType("TEXT") + .HasDefaultValue(new List()); + #endregion + + #region User & Preferences + builder.Entity() + .HasMany(ur => ur.UserRoles) + .WithOne(u => u.User) + .HasForeignKey(ur => ur.UserId) + .IsRequired(); + + builder.Entity() + .HasMany(ur => ur.UserRoles) + .WithOne(u => u.Role) + .HasForeignKey(ur => ur.RoleId) + .IsRequired(); + builder.Entity() .Property(b => b.BookThemeName) .HasDefaultValue("Dark"); @@ -162,150 +223,6 @@ public sealed class DataContext : IdentityDbContext b.PromptForRereadsAfter) .HasDefaultValue(30); - builder.Entity() - .Property(b => b.AllowScrobbling) - .HasDefaultValue(true); - builder.Entity() - .Property(b => b.AllowMetadataMatching) - .HasDefaultValue(true); - builder.Entity() - .Property(b => b.EnableMetadata) - .HasDefaultValue(true); - builder.Entity() - .Property(l => l.DefaultLanguage) - .HasDefaultValue(string.Empty); - - builder.Entity() - .Property(b => b.WebLinks) - .HasDefaultValue(string.Empty); - builder.Entity() - .Property(b => b.WebLinks) - .HasDefaultValue(string.Empty); - - builder.Entity() - .Property(b => b.ISBN) - .HasDefaultValue(string.Empty); - - builder.Entity() - .Property(b => b.StreamType) - .HasDefaultValue(DashboardStreamType.SmartFilter); - builder.Entity() - .HasIndex(e => e.Visible) - .IsUnique(false); - - builder.Entity() - .Property(b => b.StreamType) - .HasDefaultValue(SideNavStreamType.SmartFilter); - builder.Entity() - .HasIndex(e => e.Visible) - .IsUnique(false); - - builder.Entity() - .HasOne(em => em.Series) - .WithOne(s => s.ExternalSeriesMetadata) - .HasForeignKey(em => em.SeriesId) - .OnDelete(DeleteBehavior.Cascade); - - builder.Entity() - .Property(b => b.AgeRating) - .HasDefaultValue(AgeRating.Unknown); - - // Configure the many-to-many relationship for Movie and Person - builder.Entity() - .HasKey(cp => new { cp.ChapterId, cp.PersonId, cp.Role }); - - builder.Entity() - .HasOne(cp => cp.Chapter) - .WithMany(c => c.People) - .HasForeignKey(cp => cp.ChapterId); - - builder.Entity() - .HasOne(cp => cp.Person) - .WithMany(p => p.ChapterPeople) - .HasForeignKey(cp => cp.PersonId) - .OnDelete(DeleteBehavior.Cascade); - - - builder.Entity() - .HasKey(smp => new { smp.SeriesMetadataId, smp.PersonId, smp.Role }); - - builder.Entity() - .HasOne(smp => smp.SeriesMetadata) - .WithMany(sm => sm.People) - .HasForeignKey(smp => smp.SeriesMetadataId); - - builder.Entity() - .HasOne(smp => smp.Person) - .WithMany(p => p.SeriesMetadataPeople) - .HasForeignKey(smp => smp.PersonId) - .OnDelete(DeleteBehavior.Cascade); - - builder.Entity() - .Property(b => b.OrderWeight) - .HasDefaultValue(0); - - builder.Entity() - .Property(x => x.AgeRatingMappings) - .HasJsonConversion([]); - - // Ensure blacklist is stored as a JSON array - builder.Entity() - .Property(x => x.Blacklist) - .HasJsonConversion([]); - builder.Entity() - .Property(x => x.Whitelist) - .HasJsonConversion([]); - builder.Entity() - .Property(x => x.Overrides) - .HasJsonConversion([]); - - // Configure one-to-many relationship - builder.Entity() - .HasMany(x => x.FieldMappings) - .WithOne(x => x.MetadataSettings) - .HasForeignKey(x => x.MetadataSettingsId) - .OnDelete(DeleteBehavior.Cascade); - - builder.Entity() - .Property(b => b.Enabled) - .HasDefaultValue(true); - builder.Entity() - .Property(b => b.EnableCoverImage) - .HasDefaultValue(true); - - builder.Entity() - .Property(b => b.BookThemeName) - .HasDefaultValue("Dark"); - builder.Entity() - .Property(b => b.BackgroundColor) - .HasDefaultValue("#000000"); - builder.Entity() - .Property(b => b.BookReaderWritingStyle) - .HasDefaultValue(WritingStyle.Horizontal); - builder.Entity() - .Property(b => b.AllowAutomaticWebtoonReaderDetection) - .HasDefaultValue(true); - - builder.Entity() - .Property(rp => rp.LibraryIds) - .HasJsonConversion([]) - .HasColumnType("TEXT"); - builder.Entity() - .Property(rp => rp.SeriesIds) - .HasJsonConversion([]) - .HasColumnType("TEXT"); - - builder.Entity() - .Property(sm => sm.KPlusOverrides) - .HasJsonConversion([]) - .HasColumnType("TEXT") - .HasDefaultValue(new List()); - builder.Entity() - .Property(sm => sm.KPlusOverrides) - .HasJsonConversion([]) - .HasColumnType("TEXT") - .HasDefaultValue(new List()); - builder.Entity() .Property(a => a.BookReaderHighlightSlots) .HasJsonConversion([]) @@ -333,11 +250,60 @@ public sealed class DataContext : IdentityDbContext() + .Property(b => b.BookThemeName) + .HasDefaultValue("Dark"); + builder.Entity() + .Property(b => b.BackgroundColor) + .HasDefaultValue("#000000"); + builder.Entity() + .Property(b => b.BookReaderWritingStyle) + .HasDefaultValue(WritingStyle.Horizontal); + builder.Entity() + .Property(b => b.AllowAutomaticWebtoonReaderDetection) + .HasDefaultValue(true); + + builder.Entity() + .Property(rp => rp.LibraryIds) + .HasJsonConversion([]) + .HasColumnType("TEXT"); + builder.Entity() + .Property(rp => rp.SeriesIds) + .HasJsonConversion([]) + .HasColumnType("TEXT"); + #endregion + + #region AppUser Streams + + builder.Entity() + .Property(b => b.StreamType) + .HasDefaultValue(DashboardStreamType.SmartFilter); + builder.Entity() + .HasIndex(e => e.Visible) + .IsUnique(false); + + builder.Entity() + .Property(b => b.StreamType) + .HasDefaultValue(SideNavStreamType.SmartFilter); + builder.Entity() + .HasIndex(e => e.Visible) + .IsUnique(false); + + #endregion + + #region Annoations builder.Entity() .PrimitiveCollection(a => a.Likes) .HasDefaultValue(new List()); + #endregion + + + #region Reading Sessions & History builder.Entity() .Property(b => b.IsActive) .HasDefaultValue(true); @@ -361,7 +327,9 @@ public sealed class DataContext : IdentityDbContext()); + #endregion + #region Client Device builder.Entity() .Property(sm => sm.CurrentClientInfo) .HasJsonConversion(new ClientInfoData()) @@ -373,6 +341,9 @@ public sealed class DataContext : IdentityDbContext() .HasMany(sm => sm.Tags) @@ -384,9 +355,138 @@ public sealed class DataContext : IdentityDbContext t.SeriesMetadatas) .UsingEntity(); + builder.Entity() + .Property(sm => sm.KPlusOverrides) + .HasJsonConversion([]) + .HasColumnType("TEXT") + .HasDefaultValue(new List()); + + builder.Entity() + .HasKey(smp => new { smp.SeriesMetadataId, smp.PersonId, smp.Role }); + + builder.Entity() + .HasOne(smp => smp.SeriesMetadata) + .WithMany(sm => sm.People) + .HasForeignKey(smp => smp.SeriesMetadataId); + + builder.Entity() + .HasOne(smp => smp.Person) + .WithMany(p => p.SeriesMetadataPeople) + .HasForeignKey(smp => smp.PersonId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .Property(b => b.OrderWeight) + .HasDefaultValue(0); + + builder.Entity() + .Property(x => x.AgeRatingMappings) + .HasJsonConversion([]); + + builder.Entity() + .Property(b => b.WebLinks) + .HasDefaultValue(string.Empty); + + // Ensure blacklist is stored as a JSON array + builder.Entity() + .Property(x => x.Blacklist) + .HasJsonConversion([]); + builder.Entity() + .Property(x => x.Whitelist) + .HasJsonConversion([]); + builder.Entity() + .Property(x => x.Overrides) + .HasJsonConversion([]); + + // Configure one-to-many relationship + builder.Entity() + .HasMany(x => x.FieldMappings) + .WithOne(x => x.MetadataSettings) + .HasForeignKey(x => x.MetadataSettingsId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .Property(b => b.Enabled) + .HasDefaultValue(true); + builder.Entity() + .Property(b => b.EnableCoverImage) + .HasDefaultValue(true); + + #endregion + + #region AppUserAuthKey builder.Entity() .Property(a => a.Provider) .HasDefaultValue(AuthKeyProvider.User); + #endregion + + #region AppUserBookmark + builder.Entity(entity => + { + entity.HasOne(b => b.Series) + .WithMany() + .HasForeignKey(b => b.SeriesId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(b => b.Volume) + .WithMany() + .HasForeignKey(b => b.VolumeId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(b => b.Chapter) + .WithMany() + .HasForeignKey(b => b.ChapterId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(b => b.AppUser) + .WithMany(u => u.Bookmarks) + .HasForeignKey(b => b.AppUserId) + .OnDelete(DeleteBehavior.Cascade); + }); + #endregion + + #region Search Indexes + // Series indexes for search + builder.Entity(entity => + { + entity.HasIndex(s => s.NormalizedName) + .HasDatabaseName("IX_Series_NormalizedName"); + + entity.HasIndex(s => s.LibraryId) + .HasDatabaseName("IX_Series_LibraryId"); + }); + + builder.Entity(entity => + { + entity.HasIndex(sm => sm.AgeRating) + .HasDatabaseName("IX_SeriesMetadata_AgeRating"); + + // This composite helps age-restricted queries + entity.HasIndex(sm => new { sm.SeriesId, sm.AgeRating }) + .HasDatabaseName("IX_SeriesMetadata_SeriesId_AgeRating"); + }); + + // Chapter indexes + builder.Entity(entity => + { + entity.HasIndex(c => c.TitleName) + .HasDatabaseName("IX_Chapter_TitleName"); + }); + + // MangaFile indexes (admin search) + builder.Entity(entity => + { + entity.HasIndex(f => f.FilePath) + .HasDatabaseName("IX_MangaFile_FilePath"); + }); + + // AppUserBookmark composite for user lookups + builder.Entity(entity => + { + entity.HasIndex(b => new { b.AppUserId, b.SeriesId }) + .HasDatabaseName("IX_AppUserBookmark_AppUserId_SeriesId"); + }); + #endregion } #nullable enable diff --git a/API/Data/Metadata/ComicInfo.cs b/API/Data/Metadata/ComicInfo.cs index ade6fc4de..5c65f368b 100644 --- a/API/Data/Metadata/ComicInfo.cs +++ b/API/Data/Metadata/ComicInfo.cs @@ -182,21 +182,7 @@ public class ComicInfo info.Locations = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Locations); // We need to convert GTIN to ISBN - if (!string.IsNullOrEmpty(info.GTIN)) - { - // This is likely a valid ISBN - if (info.GTIN[0] == '0') - { - var potentialISBN = info.GTIN.Substring(1, info.GTIN.Length - 1); - if (ArticleNumberHelper.IsValidIsbn13(potentialISBN)) - { - info.Isbn = potentialISBN; - } - } else if (ArticleNumberHelper.IsValidIsbn10(info.GTIN) || ArticleNumberHelper.IsValidIsbn13(info.GTIN)) - { - info.Isbn = info.GTIN; - } - } + info.Isbn = ParseGtin(info.GTIN); if (!string.IsNullOrEmpty(info.Number)) { @@ -235,5 +221,34 @@ public class ComicInfo return 0; } + /// + /// For a given GTIN, attempts to parse out an ISBN and set the Isbn property. + /// + /// + /// + public static string ParseGtin(string? gtin) + { + if (string.IsNullOrEmpty(gtin)) return string.Empty; + + + // This is likely a valid ISBN + if (gtin[0] == '0') + { + var offset = gtin[1] == '-' ? 0 : 1; + var potentialIsbn = gtin[offset..]; + if (ArticleNumberHelper.IsValidIsbn13(potentialIsbn)) + { + return potentialIsbn; + } + } + + if (ArticleNumberHelper.IsValidIsbn10(gtin) || ArticleNumberHelper.IsValidIsbn13(gtin)) + { + return gtin; + } + + return string.Empty; + } + } diff --git a/API/Data/Migrations/20251210145923_BookmarkRelationshipAndSearchIndex.Designer.cs b/API/Data/Migrations/20251210145923_BookmarkRelationshipAndSearchIndex.Designer.cs new file mode 100644 index 000000000..8f4cfdfdf --- /dev/null +++ b/API/Data/Migrations/20251210145923_BookmarkRelationshipAndSearchIndex.Designer.cs @@ -0,0 +1,4426 @@ +// +using System; +using System.Collections.Generic; +using API.Data; +using API.Entities.MetadataMatching; +using API.Entities.Progress; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20251210145923_BookmarkRelationshipAndSearchIndex")] + partial class BookmarkRelationshipAndSearchIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.0"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("IdentityProvider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("OidcId") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserAnnotation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("CommentHtml") + .HasColumnType("TEXT"); + + b.Property("CommentPlainText") + .HasColumnType("TEXT"); + + b.Property("ContainsSpoiler") + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingXPath") + .HasColumnType("TEXT"); + + b.Property("HighlightCount") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("Likes") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SelectedSlotIndex") + .HasColumnType("INTEGER"); + + b.Property("SelectedText") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserAnnotation"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("ImageOffset") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.HasIndex("AppUserId", "SeriesId") + .HasDatabaseName("IX_AppUserBookmark_AppUserId_SeriesId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("LibraryIds") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SeriesIds") + .HasColumnType("TEXT"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SelectedText") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TitleName") + .HasDatabaseName("IX_Chapter_TitleName"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ClientDeviceHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CapturedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ClientInfo") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"UserAgent\":\"\",\"IpAddress\":\"\",\"AuthType\":0,\"ClientType\":0,\"AppVersion\":null,\"Browser\":null,\"BrowserVersion\":null,\"Platform\":0,\"DeviceType\":null,\"ScreenWidth\":null,\"ScreenHeight\":null,\"Orientation\":null,\"CapturedAt\":\"0001-01-01T00:00:00\"}"); + + b.Property("DeviceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.ToTable("ClientDeviceHistory"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("API.Entities.EpubFont", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("EpubFont"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DefaultLanguage") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("InheritWebLinksFromFirstChapter") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("RemovePrefixForSortName") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("FilePath") + .HasDatabaseName("IX_MangaFile_FilePath"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AgeRating") + .HasDatabaseName("IX_SeriesMetadata_AgeRating"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.HasIndex("SeriesId", "AgeRating") + .HasDatabaseName("IX_SeriesMetadata_SeriesId_AgeRating"); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableExtendedMetadataProcessing") + .HasColumnType("INTEGER"); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.Progress.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TotalReads") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.Progress.AppUserReadingHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ClientInfoUsed") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Data") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"TotalMinutesRead\":0,\"TotalPagesRead\":0,\"TotalWordsRead\":0,\"LongestSessionMinutes\":0,\"SeriesIds\":null,\"ChapterIds\":null}"); + + b.Property("DateUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("DateUtc") + .IsUnique(); + + b.ToTable("AppUserReadingHistory"); + }); + + modelBuilder.Entity("API.Entities.Progress.AppUserReadingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("EndTimeUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("StartTimeUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("IsActive"); + + b.ToTable("AppUserReadingSession"); + }); + + modelBuilder.Entity("API.Entities.Progress.AppUserReadingSessionActivityData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserReadingSessionId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("DeviceIds") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EndBookScrollId") + .HasColumnType("TEXT"); + + b.Property("EndPage") + .HasColumnType("INTEGER"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("EndTimeUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("StartBookScrollId") + .HasColumnType("TEXT"); + + b.Property("StartPage") + .HasColumnType("INTEGER"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("StartTimeUtc") + .HasColumnType("TEXT"); + + b.Property("TotalPages") + .HasColumnType("INTEGER"); + + b.Property("TotalWords") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordsRead") + .HasColumnType("INTEGER"); + + b.ComplexProperty(typeof(Dictionary), "ClientInfo", "API.Entities.Progress.AppUserReadingSessionActivityData.ClientInfo#ClientInfoData", b1 => + { + b1.Property("AppVersion"); + + b1.Property("AuthType"); + + b1.Property("Browser"); + + b1.Property("BrowserVersion"); + + b1.Property("CapturedAt"); + + b1.Property("ClientType"); + + b1.Property("DeviceType"); + + b1.Property("IpAddress") + .IsRequired(); + + b1.Property("Orientation"); + + b1.Property("Platform"); + + b1.Property("ScreenHeight"); + + b1.Property("ScreenWidth"); + + b1.Property("UserAgent") + .IsRequired(); + + b1.ToJson("ClientInfo"); + }); + + b.HasKey("Id"); + + b.HasIndex("AppUserReadingSessionId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserReadingSessionActivityData"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId") + .HasDatabaseName("IX_Series_LibraryId"); + + b.HasIndex("NormalizedName") + .HasDatabaseName("IX_Series_NormalizedName"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("API.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("API.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.User.AppUserAuthKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastAccessedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("AppUserAuthKey"); + }); + + modelBuilder.Entity("API.Entities.User.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("API.Entities.User.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderHighlightSlots") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("ColorScapeEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CustomKeyBinds") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{}"); + + b.Property("DataSaver") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("OpdsPreferences") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"EmbedProgressIndicator\":true,\"IncludeContinueFrom\":true}"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("PromptForRereadsAfter") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SocialPreferences") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"ShareReviews\":false,\"ShareAnnotations\":false,\"ViewOtherAnnotations\":false,\"SocialLibraries\":[],\"SocialMaxAgeRating\":-1,\"SocialIncludeUnknowns\":true,\"ShareProfile\":false}"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.User.ClientDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CurrentClientInfo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"UserAgent\":\"\",\"IpAddress\":\"\",\"AuthType\":0,\"ClientType\":0,\"AppVersion\":null,\"Browser\":null,\"BrowserVersion\":null,\"Platform\":0,\"DeviceType\":null,\"ScreenWidth\":null,\"ScreenHeight\":null,\"Orientation\":null,\"CapturedAt\":\"0001-01-01T00:00:00\"}"); + + b.Property("DeviceFingerprint") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstSeenUtc") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("UiFingerprint") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ClientDevice"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("API.Entities.AppUserAnnotation", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Annotations") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.AppUserCollection", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserDashboardStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserExternalSource", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserReadingProfile", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.AppUserSideNavStream", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("API.Entities.AppUserSmartFilter", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserTableOfContent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.AppUserWantToRead", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.ClientDeviceHistory", b => + { + b.HasOne("API.Entities.User.ClientDevice", "Device") + .WithMany("History") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("API.Entities.Device", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.EmailHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryExcludePattern", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalRating", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalReview", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("API.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("API.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.GenreSeriesMetadata", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadataTag", b => + { + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("API.Entities.MetadataFieldMapping", b => + { + b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("API.Entities.Person.ChapterPeople", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.PersonAlias", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("API.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Progress.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.Progress.AppUserReadingHistory", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingHistory") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.Progress.AppUserReadingSession", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingSessions") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.Progress.AppUserReadingSessionActivityData", b => + { + b.HasOne("API.Entities.Progress.AppUserReadingSession", "ReadingSession") + .WithMany("ActivityData") + .HasForeignKey("AppUserReadingSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingSession"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("API.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.User.AppUserAuthKey", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("AuthKeys") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.User.AppUserChapterRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.User.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.User.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("API.Entities.User.ClientDevice", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ClientDevices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("API.Entities.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("API.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Annotations"); + + b.Navigation("AuthKeys"); + + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("ClientDevices"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingHistory"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ReadingSessions"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences") + .IsRequired(); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("API.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("API.Entities.Progress.AppUserReadingSession", b => + { + b.Navigation("ActivityData"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.User.ClientDevice", b => + { + b.Navigation("History"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20251210145923_BookmarkRelationshipAndSearchIndex.cs b/API/Data/Migrations/20251210145923_BookmarkRelationshipAndSearchIndex.cs new file mode 100644 index 000000000..93cf79fbf --- /dev/null +++ b/API/Data/Migrations/20251210145923_BookmarkRelationshipAndSearchIndex.cs @@ -0,0 +1,153 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + /// + public partial class BookmarkRelationshipAndSearchIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Clean orphaned records BEFORE adding FK constraints + migrationBuilder.Sql(@" + DELETE FROM AppUserBookmark + WHERE SeriesId NOT IN (SELECT Id FROM Series) + OR VolumeId NOT IN (SELECT Id FROM Volume) + OR ChapterId NOT IN (SELECT Id FROM Chapter); + "); + + + migrationBuilder.DropIndex( + name: "IX_AppUserBookmark_AppUserId", + table: "AppUserBookmark"); + + migrationBuilder.CreateIndex( + name: "IX_SeriesMetadata_AgeRating", + table: "SeriesMetadata", + column: "AgeRating"); + + migrationBuilder.CreateIndex( + name: "IX_SeriesMetadata_SeriesId_AgeRating", + table: "SeriesMetadata", + columns: new[] { "SeriesId", "AgeRating" }); + + migrationBuilder.CreateIndex( + name: "IX_Series_NormalizedName", + table: "Series", + column: "NormalizedName"); + + migrationBuilder.CreateIndex( + name: "IX_MangaFile_FilePath", + table: "MangaFile", + column: "FilePath"); + + migrationBuilder.CreateIndex( + name: "IX_Chapter_TitleName", + table: "Chapter", + column: "TitleName"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserBookmark_AppUserId_SeriesId", + table: "AppUserBookmark", + columns: new[] { "AppUserId", "SeriesId" }); + + migrationBuilder.CreateIndex( + name: "IX_AppUserBookmark_ChapterId", + table: "AppUserBookmark", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserBookmark_SeriesId", + table: "AppUserBookmark", + column: "SeriesId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserBookmark_VolumeId", + table: "AppUserBookmark", + column: "VolumeId"); + + migrationBuilder.AddForeignKey( + name: "FK_AppUserBookmark_Chapter_ChapterId", + table: "AppUserBookmark", + column: "ChapterId", + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_AppUserBookmark_Series_SeriesId", + table: "AppUserBookmark", + column: "SeriesId", + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_AppUserBookmark_Volume_VolumeId", + table: "AppUserBookmark", + column: "VolumeId", + principalTable: "Volume", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_AppUserBookmark_Chapter_ChapterId", + table: "AppUserBookmark"); + + migrationBuilder.DropForeignKey( + name: "FK_AppUserBookmark_Series_SeriesId", + table: "AppUserBookmark"); + + migrationBuilder.DropForeignKey( + name: "FK_AppUserBookmark_Volume_VolumeId", + table: "AppUserBookmark"); + + migrationBuilder.DropIndex( + name: "IX_SeriesMetadata_AgeRating", + table: "SeriesMetadata"); + + migrationBuilder.DropIndex( + name: "IX_SeriesMetadata_SeriesId_AgeRating", + table: "SeriesMetadata"); + + migrationBuilder.DropIndex( + name: "IX_Series_NormalizedName", + table: "Series"); + + migrationBuilder.DropIndex( + name: "IX_MangaFile_FilePath", + table: "MangaFile"); + + migrationBuilder.DropIndex( + name: "IX_Chapter_TitleName", + table: "Chapter"); + + migrationBuilder.DropIndex( + name: "IX_AppUserBookmark_AppUserId_SeriesId", + table: "AppUserBookmark"); + + migrationBuilder.DropIndex( + name: "IX_AppUserBookmark_ChapterId", + table: "AppUserBookmark"); + + migrationBuilder.DropIndex( + name: "IX_AppUserBookmark_SeriesId", + table: "AppUserBookmark"); + + migrationBuilder.DropIndex( + name: "IX_AppUserBookmark_VolumeId", + table: "AppUserBookmark"); + + migrationBuilder.CreateIndex( + name: "IX_AppUserBookmark_AppUserId", + table: "AppUserBookmark", + column: "AppUserId"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 55a0355f1..0dc52a7c4 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -306,7 +306,14 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.HasIndex("AppUserId"); + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.HasIndex("AppUserId", "SeriesId") + .HasDatabaseName("IX_AppUserBookmark_AppUserId_SeriesId"); b.ToTable("AppUserBookmark"); }); @@ -985,6 +992,9 @@ namespace API.Data.Migrations b.HasKey("Id"); + b.HasIndex("TitleName") + .HasDatabaseName("IX_Chapter_TitleName"); + b.HasIndex("VolumeId"); b.ToTable("Chapter"); @@ -1418,6 +1428,9 @@ namespace API.Data.Migrations b.HasIndex("ChapterId"); + b.HasIndex("FilePath") + .HasDatabaseName("IX_MangaFile_FilePath"); + b.ToTable("MangaFile"); }); @@ -1754,12 +1767,18 @@ namespace API.Data.Migrations b.HasKey("Id"); + b.HasIndex("AgeRating") + .HasDatabaseName("IX_SeriesMetadata_AgeRating"); + b.HasIndex("SeriesId") .IsUnique(); b.HasIndex("Id", "SeriesId") .IsUnique(); + b.HasIndex("SeriesId", "AgeRating") + .HasDatabaseName("IX_SeriesMetadata_SeriesId_AgeRating"); + b.ToTable("SeriesMetadata"); }); @@ -2629,7 +2648,11 @@ namespace API.Data.Migrations b.HasKey("Id"); - b.HasIndex("LibraryId"); + b.HasIndex("LibraryId") + .HasDatabaseName("IX_Series_LibraryId"); + + b.HasIndex("NormalizedName") + .HasDatabaseName("IX_Series_NormalizedName"); b.ToTable("Series"); }); @@ -3383,7 +3406,31 @@ namespace API.Data.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + + b.Navigation("Volume"); }); modelBuilder.Entity("API.Entities.AppUserCollection", b => diff --git a/API/Data/Repositories/AppUserProgressRepository.cs b/API/Data/Repositories/AppUserProgressRepository.cs index a9d11bb17..36676c256 100644 --- a/API/Data/Repositories/AppUserProgressRepository.cs +++ b/API/Data/Repositories/AppUserProgressRepository.cs @@ -36,6 +36,8 @@ public interface IAppUserProgressRepository Task GetHighestFullyReadChapterForSeries(int seriesId, int userId); Task GetHighestFullyReadVolumeForSeries(int seriesId, int userId); Task GetLatestProgressForSeries(int seriesId, int userId); + Task GetLatestProgressForVolume(int volumeId, int userId); + Task GetLatestProgressForChapter(int chapterId, int userId); Task GetFirstProgressForSeries(int seriesId, int userId); Task UpdateAllProgressThatAreMoreThanChapterPages(); Task> GetUserProgressForChapter(int chapterId, int userId = 0); @@ -203,6 +205,22 @@ public class AppUserProgressRepository : IAppUserProgressRepository return list.Count == 0 ? null : list.DefaultIfEmpty().Max(); } + public async Task GetLatestProgressForVolume(int volumeId, int userId) + { + var list = await _context.AppUserProgresses.Where(p => p.AppUserId == userId && p.VolumeId == volumeId) + .Select(p => p.LastModifiedUtc) + .ToListAsync(); + return list.Count == 0 ? null : list.DefaultIfEmpty().Max(); + } + + public async Task GetLatestProgressForChapter(int chapterId, int userId) + { + return await _context.AppUserProgresses + .Where(p => p.AppUserId == userId && p.ChapterId == chapterId) + .Select(p => p.LastModifiedUtc) + .FirstOrDefaultAsync(); + } + public async Task GetFirstProgressForSeries(int seriesId, int userId) { var list = await _context.AppUserProgresses.Where(p => p.AppUserId == userId && p.SeriesId == seriesId) diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index a030aa00f..1953bff9c 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -11,6 +11,7 @@ using API.Entities.Enums; using API.Entities.Metadata; using API.Extensions; using API.Extensions.QueryExtensions; +using API.Services.Tasks.Scanner.Parser; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; @@ -51,7 +52,7 @@ public interface IChapterRepository Task> GetAllCoverImagesAsync(); Task> GetAllChaptersWithCoversInDifferentEncoding(EncodeFormat format); Task> GetCoverImagesForLockedChaptersAsync(); - Task AddChapterModifiers(int userId, ChapterDto chapter); + Task AddChapterModifiers(int userId, ChapterDto chapter); IEnumerable GetChaptersForSeries(int seriesId); Task> GetAllChaptersForSeries(int seriesId); Task GetAverageUserRating(int chapterId, int userId); @@ -59,6 +60,8 @@ public interface IChapterRepository Task> GetExternalChapterReview(int chapterId); Task> GetExternalChapterRatingDtos(int chapterId); Task> GetExternalChapterRatings(int chapterId); + Task GetCurrentlyReadingChapterAsync(int seriesId, int userId); + Task GetFirstChapterForSeriesAsync(int seriesId, int userId); } public class ChapterRepository : IChapterRepository { @@ -318,8 +321,10 @@ public class ChapterRepository : IChapterRepository .ToListAsync(); } - public async Task AddChapterModifiers(int userId, ChapterDto chapter) + public async Task AddChapterModifiers(int userId, ChapterDto? chapter) { + if (chapter == null) return; + var progress = await _context.AppUserProgresses.Where(x => x.AppUserId == userId && x.ChapterId == chapter.Id) .AsNoTracking() @@ -337,8 +342,6 @@ public class ChapterRepository : IChapterRepository chapter.LastReadingProgressUtc = DateTime.MinValue; chapter.LastReadingProgress = DateTime.MinValue; } - - return chapter; } /// @@ -416,4 +419,61 @@ public class ChapterRepository : IChapterRepository .SelectMany(c => c.ExternalRatings) .ToListAsync(); } + + public async Task GetCurrentlyReadingChapterAsync(int seriesId, int userId) + { + var chapterWithProgress = await _context.AppUserProgresses + .Where(p => p.AppUserId == userId) + .Join( + _context.Chapter.Include(c => c.Volume), + p => p.ChapterId, + c => c.Id, + (p, c) => new { Chapter = c, p.PagesRead } + ) + .Where(x => x.Chapter.Volume.SeriesId == seriesId) + .Where(x => x.Chapter.Volume.Number != Parser.LooseLeafVolumeNumber) + .Where(x => x.PagesRead > 0 && x.PagesRead < x.Chapter.Pages) + .OrderBy(x => x.Chapter.Volume.Number) + .ThenBy(x => x.Chapter.SortOrder) + .AsNoTracking() + .FirstOrDefaultAsync(); + + if (chapterWithProgress == null) return null; + + // Map chapter to DTO + var dto = _mapper.Map(chapterWithProgress.Chapter); + dto.PagesRead = chapterWithProgress.PagesRead; + + return dto; + } + + public async Task GetFirstChapterForSeriesAsync(int seriesId, int userId) + { + // Get the chapter entity with proper ordering + var firstChapter = await _context.Chapter + .Include(c => c.Volume) + .Where(c => c.Volume.SeriesId == seriesId) + .OrderBy(c => + // Priority 1: Regular volumes (not loose leaf, not special) + c.Volume.Number == Parser.LooseLeafVolumeNumber || + c.Volume.Number == Parser.SpecialVolumeNumber ? 1 : 0) + .ThenBy(c => + // Priority 2: Loose leaf over specials + c.Volume.Number == Parser.SpecialVolumeNumber ? 1 : 0) + .ThenBy(c => + // Priority 3: Non-special chapters + c.IsSpecial ? 1 : 0) + .ThenBy(c => c.Volume.Number) + .ThenBy(c => c.SortOrder) + .AsNoTracking() + .FirstOrDefaultAsync(); + + if (firstChapter == null) return null; + + var dto = _mapper.Map(firstChapter); + + await AddChapterModifiers(userId, dto); + + return dto; + } } diff --git a/API/Data/Repositories/ExternalSeriesMetadataRepository.cs b/API/Data/Repositories/ExternalSeriesMetadataRepository.cs index ce26dda5c..2b046c680 100644 --- a/API/Data/Repositories/ExternalSeriesMetadataRepository.cs +++ b/API/Data/Repositories/ExternalSeriesMetadataRepository.cs @@ -105,11 +105,11 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor public async Task NeedsDataRefresh(int seriesId) { // TODO: Add unit test - var row = await _context.ExternalSeriesMetadata + return await _context.ExternalSeriesMetadata .Where(s => s.SeriesId == seriesId) - .FirstOrDefaultAsync(); - - return row == null || row.ValidUntilUtc <= DateTime.UtcNow; + .Select(s => s.ValidUntilUtc) + .Where(date => date < DateTime.UtcNow) + .AnyAsync(); } public async Task GetSeriesDetailPlusDto(int seriesId) diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 47e82921b..79319f766 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -385,160 +385,165 @@ public class SeriesRepository : ISeriesRepository public async Task SearchSeries(int userId, bool isAdmin, IList libraryIds, string searchQuery, bool includeChapterAndFiles = true) { const int maxRecords = 15; - var result = new SearchResultGroupDto(); var searchQueryNormalized = searchQuery.ToNormalized(); var userRating = await _context.AppUser.GetUserAgeRestriction(userId); - var seriesIds = await _context.Series - .Where(s => libraryIds.Contains(s.LibraryId)) - .RestrictAgainstAgeRestriction(userRating) - .Select(s => s.Id) - .ToListAsync(); + var justYear = _yearRegex.Match(searchQuery).Value; + var hasYearInQuery = !string.IsNullOrEmpty(justYear); + var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0; - result.Libraries = await _context.Library + + var baseSeriesQuery = _context.Series + .Where(s => libraryIds.Contains(s.LibraryId)) + .RestrictAgainstAgeRestriction(userRating); + + #region Independent Queries + var librariesTask = _context.Library .Search(searchQuery, userId, libraryIds) .Take(maxRecords) .OrderBy(l => l.Name.ToLower()) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - result.Annotations = await _context.AppUserAnnotation + var annotationsTask = _context.AppUserAnnotation .Where(a => a.AppUserId == userId && - (EF.Functions.Like(a.Comment, $"%{searchQueryNormalized}%") || EF.Functions.Like(a.Context, $"%{searchQueryNormalized}%"))) + (EF.Functions.Like(a.Comment, $"%{searchQueryNormalized}%") || + EF.Functions.Like(a.Context, $"%{searchQueryNormalized}%"))) .Take(maxRecords) .OrderBy(l => l.CreatedUtc) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); + #endregion - var justYear = _yearRegex.Match(searchQuery).Value; - var hasYearInQuery = !string.IsNullOrEmpty(justYear); - var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0; + var seriesTask = baseSeriesQuery + .Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%") + || (s.OriginalName != null && EF.Functions.Like(s.OriginalName, $"%{searchQuery}%")) + || (s.LocalizedName != null && EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%")) + || EF.Functions.Like(s.NormalizedName, $"%{searchQueryNormalized}%") + || (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison)) + .OrderBy(s => s.SortName!.Length) + .ThenBy(s => s.SortName!.ToLower()) + .Take(maxRecords) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); - result.Series = _context.Series - .Where(s => libraryIds.Contains(s.LibraryId)) - .Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%") - || (s.OriginalName != null && EF.Functions.Like(s.OriginalName, $"%{searchQuery}%")) - || (s.LocalizedName != null && EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%")) - || (EF.Functions.Like(s.NormalizedName, $"%{searchQueryNormalized}%")) - || (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison)) - .RestrictAgainstAgeRestriction(userRating) - .Include(s => s.Library) - .AsNoTracking() - .AsSplitQuery() - .OrderBy(s => s.SortName!.Length) - .ThenBy(s => s.SortName!.ToLower()) - .Take(maxRecords) - .ProjectTo(_mapper.ConfigurationProvider) - .AsEnumerable(); - - result.Bookmarks = (await _context.AppUserBookmark - .Join( - _context.Series, - bookmark => bookmark.SeriesId, - series => series.Id, - (bookmark, series) => new {Bookmark = bookmark, Series = series} - ) - .Where(joined => joined.Bookmark.AppUserId == userId && - (EF.Functions.Like(joined.Series.Name, $"%{searchQuery}%") || - (joined.Series.OriginalName != null && - EF.Functions.Like(joined.Series.OriginalName, $"%{searchQuery}%")) || - (joined.Series.LocalizedName != null && - EF.Functions.Like(joined.Series.LocalizedName, $"%{searchQuery}%")))) - .OrderBy(joined => joined.Series.NormalizedName.Length) - .ThenBy(joined => joined.Series.NormalizedName) - .Take(maxRecords) - .Select(joined => new BookmarkSearchResultDto() - { - SeriesName = joined.Series.Name, - LocalizedSeriesName = joined.Series.LocalizedName, - LibraryId = joined.Series.LibraryId, - SeriesId = joined.Bookmark.SeriesId, - ChapterId = joined.Bookmark.ChapterId, - VolumeId = joined.Bookmark.VolumeId - }) - .ToListAsync()).DistinctBy(s => s.SeriesId); - - - result.ReadingLists = await _context.ReadingList + var readingListsTask = _context.ReadingList .Search(searchQuery, userId, userRating) .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - result.Collections = await _context.AppUserCollection + var collectionsTask = _context.AppUserCollection .Search(searchQuery, userId, userRating) .Take(maxRecords) .OrderBy(c => c.NormalizedTitle) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - // I can't work out how to map people in DB layer - var personIds = await _context.SeriesMetadata - .SearchPeople(searchQuery, seriesIds) - .Select(p => p.Id) + var bookmarksTask = _context.AppUserBookmark + .Where(b => b.AppUserId == userId) + .Where(b => libraryIds.Contains(b.Series.LibraryId)) + .Where(b => EF.Functions.Like(b.Series.Name, $"%{searchQuery}%") || + (b.Series.OriginalName != null && EF.Functions.Like(b.Series.OriginalName, $"%{searchQuery}%")) || + (b.Series.LocalizedName != null && EF.Functions.Like(b.Series.LocalizedName, $"%{searchQuery}%"))) + .OrderBy(b => b.Series.NormalizedName.Length) + .ThenBy(b => b.Series.NormalizedName) + .Select(b => new BookmarkSearchResultDto + { + SeriesName = b.Series.Name, + LocalizedSeriesName = b.Series.LocalizedName, + LibraryId = b.Series.LibraryId, + SeriesId = b.SeriesId, + ChapterId = b.ChapterId, + VolumeId = b.VolumeId + }) .Distinct() - .OrderBy(id => id) .Take(maxRecords) .ToListAsync(); - result.Persons = await _context.Person - .Where(p => personIds.Contains(p.Id)) + var seriesIdsSubquery = baseSeriesQuery.Select(s => s.Id); + + var personsTask = _context.Person + .Where(p => _context.SeriesMetadataPeople + .Any(smp => smp.PersonId == p.Id && + seriesIdsSubquery.Contains(smp.SeriesMetadata.SeriesId) && + EF.Functions.Like(p.NormalizedName, $"%{searchQueryNormalized}%"))) .OrderBy(p => p.NormalizedName.Length) .ThenBy(p => p.NormalizedName) + .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - result.Genres = await _context.SeriesMetadata - .SearchGenres(searchQuery, seriesIds) + var genresTask = _context.Genre + .Where(g => _context.SeriesMetadata + .Any(sm => seriesIdsSubquery.Contains(sm.SeriesId) && + sm.Genres.Any(sg => sg.Id == g.Id)) && + EF.Functions.Like(g.NormalizedTitle, $"%{searchQueryNormalized}%")) .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - result.Tags = await _context.SeriesMetadata - .SearchTags(searchQuery, seriesIds) + var tagsTask = _context.Tag + .Where(t => _context.SeriesMetadata + .Any(sm => seriesIdsSubquery.Contains(sm.SeriesId) && + sm.Tags.Any(st => st.Id == t.Id)) && + EF.Functions.Like(t.NormalizedTitle, $"%{searchQueryNormalized}%")) .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); - result.Files = []; - result.Chapters = (List) []; + // Run separate DB queries in parallel + await Task.WhenAll( + librariesTask, annotationsTask, seriesTask, readingListsTask, + collectionsTask, bookmarksTask, personsTask, genresTask, tagsTask); + var result = new SearchResultGroupDto + { + Libraries = await librariesTask, + Annotations = await annotationsTask, + Series = await seriesTask, + ReadingLists = await readingListsTask, + Collections = await collectionsTask, + Bookmarks = await bookmarksTask, + Persons = await personsTask, + Genres = await genresTask, + Tags = await tagsTask, + Files = [], + Chapters = [] + }; if (includeChapterAndFiles) { - var fileIds = _context.Series - .Where(s => seriesIds.Contains(s.Id)) - .AsSplitQuery() - .SelectMany(s => s.Volumes) - .SelectMany(v => v.Chapters) - .SelectMany(c => c.Files.Select(f => f.Id)); - - // Need to check if an admin - var user = await _context.AppUser.FirstAsync(u => u.Id == userId); - if (await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole)) - { - result.Files = await _context.MangaFile - .Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id)) - .AsSplitQuery() - .OrderBy(f => f.FilePath) - .Take(maxRecords) - .ProjectTo(_mapper.ConfigurationProvider) - .ToListAsync(); - } - - result.Chapters = await _context.Chapter - .Include(c => c.Files) + // Use EXISTS subquery pattern instead of loading IDs + var chaptersQuery = _context.Chapter + .Where(c => c.Volume.Series.LibraryId > 0 && // Ensure navigation works + libraryIds.Contains(c.Volume.Series.LibraryId)) .Where(c => EF.Functions.Like(c.TitleName, $"%{searchQuery}%") || EF.Functions.Like(c.ISBN, $"%{searchQuery}%") - || EF.Functions.Like(c.Range, $"%{searchQuery}%") - ) - .Where(c => c.Files.All(f => fileIds.Contains(f.Id))) - .AsSplitQuery() + || EF.Functions.Like(c.Range, $"%{searchQuery}%")); + + // Apply age restriction via series + chaptersQuery = chaptersQuery + .Where(c => baseSeriesQuery.Any(s => s.Id == c.Volume.SeriesId)); + + result.Chapters = await chaptersQuery .OrderBy(c => c.TitleName.Length) .ThenBy(c => c.TitleName) .Take(maxRecords) .ProjectTo(_mapper.ConfigurationProvider) .ToListAsync(); + + if (isAdmin) + { + result.Files = await _context.MangaFile + .Where(f => EF.Functions.Like(f.FilePath, $"%{searchQuery}%")) + .Where(f => libraryIds.Contains(f.Chapter.Volume.Series.LibraryId)) + .Where(f => baseSeriesQuery.Any(s => s.Id == f.Chapter.Volume.SeriesId)) + .OrderBy(f => f.FilePath) + .Take(maxRecords) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } } return result; diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index ba3e61e43..3240c8c3e 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -133,10 +133,11 @@ public interface IUserRepository Task GetCoverImageAsync(int userId, int requestingUserId); Task GetPersonCoverImageAsync(int personId); Task> GetAuthKeysForUserId(int userId); + Task> GetAllAuthKeysDtosWithExpiration(); Task GetAuthKeyById(int authKeyId); + Task GetAuthKeyExpiration(string authKey, int userId); Task GetSocialPreferencesForUser(int userId); Task GetPreferencesForUser(int userId); - } public class UserRepository : IUserRepository @@ -970,6 +971,9 @@ public class UserRepository : IUserRepository return await _context.AppUserAuthKey .Where(k => k.Key == authKey) .HasNotExpired() + .Include(k => k.AppUser) + .ThenInclude(u => u.UserRoles) + .ThenInclude(ur => ur.Role) .Select(k => k.AppUser) .ProjectTo(_mapper.ConfigurationProvider) .FirstOrDefaultAsync(); @@ -1073,6 +1077,14 @@ public class UserRepository : IUserRepository .ToListAsync(); } + public async Task> GetAllAuthKeysDtosWithExpiration() + { + return await _context.AppUserAuthKey + .Where(k => k.ExpiresAtUtc != null) + .ProjectTo(_mapper.ConfigurationProvider) + .ToListAsync(); + } + public async Task GetAuthKeyById(int authKeyId) { return await _context.AppUserAuthKey @@ -1080,6 +1092,14 @@ public class UserRepository : IUserRepository .FirstOrDefaultAsync(); } + public async Task GetAuthKeyExpiration(string authKey, int userId) + { + return await _context.AppUserAuthKey + .Where(k => k.Key == authKey && k.AppUserId == userId) + .Select(k => k.ExpiresAtUtc) + .FirstOrDefaultAsync(); + } + public async Task GetSocialPreferencesForUser(int userId) { return await _context.AppUserPreferences diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index 0f2b5e68e..08de17817 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -37,12 +37,11 @@ public interface IVolumeRepository Task GetVolumeCoverImageAsync(int volumeId); Task> GetChapterIdsByVolumeIds(IReadOnlyList volumeIds); Task> GetVolumesDtoAsync(int seriesId, int userId, VolumeIncludes includes = VolumeIncludes.Chapters); - Task GetVolumeAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files); + Task GetVolumeByIdAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files); Task GetVolumeDtoAsync(int volumeId, int userId); Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false); Task> GetVolumes(int seriesId); Task> GetVolumesById(IList volumeIds, VolumeIncludes includes = VolumeIncludes.None); - Task GetVolumeByIdAsync(int volumeId); Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat); Task> GetCoverImagesForLockedVolumesAsync(); } @@ -198,7 +197,7 @@ public class VolumeRepository : IVolumeRepository /// /// /// - public async Task GetVolumeAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files) + public async Task GetVolumeByIdAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files) { return await _context.Volume .Includes(includes) @@ -228,11 +227,6 @@ public class VolumeRepository : IVolumeRepository return volumes; } - public async Task GetVolumeByIdAsync(int volumeId) - { - return await _context.Volume.FirstOrDefaultAsync(x => x.Id == volumeId); - } - public async Task> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat) { var extension = encodeFormat.GetExtension(); diff --git a/API/Entities/User/AppUserBookmark.cs b/API/Entities/User/AppUserBookmark.cs index ef55bf551..c62f8685e 100644 --- a/API/Entities/User/AppUserBookmark.cs +++ b/API/Entities/User/AppUserBookmark.cs @@ -37,6 +37,12 @@ public class AppUserBookmark : IEntityDate // Relationships [JsonIgnore] public AppUser AppUser { get; set; } = null!; + [JsonIgnore] + public Series Series { get; set; } = null!; + [JsonIgnore] + public Volume Volume { get; set; } = null!; + [JsonIgnore] + public Chapter Chapter { get; set; } = null!; public int AppUserId { get; set; } public DateTime Created { get; set; } public DateTime LastModified { get; set; } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 748fe0079..6cb6e96b7 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -4,6 +4,7 @@ using API.Data; using API.Helpers; using API.Middleware; using API.Services; +using API.Services.Caching; using API.Services.Plus; using API.Services.Reading; using API.Services.Store; @@ -13,12 +14,14 @@ using API.Services.Tasks.Scanner; using API.SignalR; using API.SignalR.Presence; using Kavita.Common; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using NeoSmart.Caching.Sqlite; namespace API.Extensions; @@ -84,6 +87,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); @@ -94,6 +98,7 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -126,7 +131,8 @@ public static class ApplicationServiceExtensions options.SizeLimit = Configuration.CacheSize * 1024 * 1024; // 75 MB options.CompactionPercentage = 0.1; // LRU compaction, Evict 10% when limit reached }); - // Needs to be registered after the memory cache, as it depends on it + + services.AddSingleton(); services.AddSingleton(); services.AddSwaggerGen(g => @@ -137,6 +143,8 @@ public static class ApplicationServiceExtensions private static void AddSqLite(this IServiceCollection services) { + services.AddSqliteCache("config/cache.db"); + services.AddDbContextPool(options => { options.UseSqlite("Data source=config/kavita.db", builder => diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index 09e371428..71e2f2320 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; using System.Text; @@ -7,7 +8,9 @@ using System.Threading.Tasks; using API.Constants; using API.Data; using API.Entities; +using API.Entities.Progress; using API.Helpers; +using API.Middleware; using API.Services; using Kavita.Common; using Microsoft.AspNetCore.Authentication; @@ -74,28 +77,39 @@ public static class IdentityServiceExtensions var auth = services.AddAuthentication(DynamicHybrid); var enableOidc = oidcSettings.Enabled && services.SetupOpenIdConnectAuthentication(auth, oidcSettings, environment); - auth.AddPolicyScheme(DynamicHybrid, JwtBearerDefaults.AuthenticationScheme, options => + auth.AddPolicyScheme(DynamicHybrid, LocalIdentity, options => { options.ForwardDefaultSelector = ctx => { - if (!enableOidc) return LocalIdentity; - - if (ctx.Request.Path.StartsWithSegments(OidcCallback) || - ctx.Request.Path.StartsWithSegments(OidcLogoutCallback)) + // Priority 1: Check for API/Auth Key + var apiKey = AuthKeyAuthenticationHandler.ExtractAuthKey(ctx.Request); + if (!string.IsNullOrEmpty(apiKey)) { - return OpenIdConnect; + return AuthKeyAuthenticationOptions.SchemeName; } + // Priority 2: OIDC paths and cookies + if (enableOidc) + { + if (ctx.Request.Path.StartsWithSegments(OidcCallback) || + ctx.Request.Path.StartsWithSegments(OidcLogoutCallback)) + { + return OpenIdConnect; + } + + if (ctx.Request.Cookies.ContainsKey(OidcService.CookieName)) + { + return OpenIdConnect; + } + } + + // Priority 3: JWT Bearer token if (ctx.Request.Headers.Authorization.Count != 0) { return LocalIdentity; } - if (ctx.Request.Cookies.ContainsKey(OidcService.CookieName)) - { - return OpenIdConnect; - } - + // Default to JWT return LocalIdentity; }; }); @@ -109,14 +123,31 @@ public static class IdentityServiceExtensions ValidateIssuer = false, ValidateAudience = false, ValidIssuer = "Kavita", + NameClaimType = JwtRegisteredClaimNames.Name, + RoleClaimType = ClaimTypes.Role, }; options.Events = new JwtBearerEvents { OnMessageReceived = SetTokenFromQuery, + OnTokenValidated = ctx => + { + (ctx.Principal?.Identity as ClaimsIdentity)?.AddClaim(new Claim("AuthType", nameof(AuthenticationType.JWT))); + return Task.CompletedTask; + } }; }); + // Add Bearer as an alias to LocalIdentity + auth.AddPolicyScheme(JwtBearerDefaults.AuthenticationScheme, JwtBearerDefaults.AuthenticationScheme, options => + { + options.ForwardDefaultSelector = _ => LocalIdentity; + }); + + auth.AddScheme( + AuthKeyAuthenticationOptions.SchemeName, + options => { }); + services.AddAuthorizationBuilder() .AddPolicy(PolicyGroups.AdminPolicy, policy => policy.RequireRole(PolicyConstants.AdminRole)) diff --git a/API/Extensions/QueryExtensions/Filtering/ActivityFilter.cs b/API/Extensions/QueryExtensions/Filtering/ActivityFilter.cs index 0e5d177ce..d2c8f9641 100644 --- a/API/Extensions/QueryExtensions/Filtering/ActivityFilter.cs +++ b/API/Extensions/QueryExtensions/Filtering/ActivityFilter.cs @@ -44,8 +44,8 @@ public static class ActivityFilter queryable = queryable .Where(d => filter.Libraries.Contains(d.LibraryId) && d.ReadingSession.AppUserId == userId) .WhereIf(onlyCompleted, d => d.EndPage >= d.Chapter.Pages) - .WhereIf(startTime != null, d => d.StartTime >= startTime) - .WhereIf(endTime != null, d => d.EndTime <= endTime); + .WhereIf(startTime != null, d => d.StartTimeUtc >= startTime) + .WhereIf(endTime != null, d => d.EndTimeUtc <= endTime); if (isAggregate) { diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 95cded3e2..8ccd1a98c 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -263,6 +263,9 @@ public class AutoMapperProfiles : Profile CreateMap() + .ForMember(dest => dest.Roles, + opt => + opt.MapFrom(src => src.UserRoles.Select(r => r.Role.Name))) .ForMember(dest => dest.AgeRestriction, opt => opt.MapFrom(src => new AgeRestrictionDto() @@ -471,5 +474,12 @@ public class AutoMapperProfiles : Profile CreateMap(); + + #region Deprecated Code + + CreateMap(); + + #endregion + } } diff --git a/API/Logging/LogLevelOptions.cs b/API/Logging/LogLevelOptions.cs index 751fa9894..47df3973d 100644 --- a/API/Logging/LogLevelOptions.cs +++ b/API/Logging/LogLevelOptions.cs @@ -48,6 +48,8 @@ public static class LogLevelOptions // Suppress noisy loggers that add no value .MinimumLevel.Override("Microsoft.AspNetCore.ResponseCaching.ResponseCachingMiddleware", LogEventLevel.Error) .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Error) + .MinimumLevel.Override("Microsoft.AspNetCore.Authentication", LogEventLevel.Error) + .MinimumLevel.Override("API.Middleware.AuthKeyAuthenticationHandler", LogEventLevel.Error) .Enrich.FromLogContext() .Enrich.WithThreadId() .Enrich.With(new ApiKeyEnricher()) diff --git a/API/Middleware/AuthKeyAuthenticationHandler.cs b/API/Middleware/AuthKeyAuthenticationHandler.cs new file mode 100644 index 000000000..c251d542b --- /dev/null +++ b/API/Middleware/AuthKeyAuthenticationHandler.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using API.Constants; +using API.Data; +using API.Entities.Progress; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace API.Middleware; +#nullable enable + +public class AuthKeyAuthenticationOptions : AuthenticationSchemeOptions +{ + public const string SchemeName = "AuthKey"; +} + +public class AuthKeyAuthenticationHandler : AuthenticationHandler +{ +private readonly IUnitOfWork _unitOfWork; + private readonly HybridCache _cache; + + private static readonly HybridCacheEntryOptions CacheOptions = new() + { + Expiration = TimeSpan.FromMinutes(15), + LocalCacheExpiration = TimeSpan.FromMinutes(15) + }; + + public AuthKeyAuthenticationHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IUnitOfWork unitOfWork, + HybridCache cache) + : base(options, logger, encoder) + { + _unitOfWork = unitOfWork; + _cache = cache; + } + + protected override async Task HandleAuthenticateAsync() + { + var apiKey = ExtractAuthKey(Request); + + if (string.IsNullOrEmpty(apiKey)) + { + return AuthenticateResult.NoResult(); + } + + try + { + var cacheKey = CreateCacheKey(apiKey); + var user = await _cache.GetOrCreateAsync( + cacheKey, + (apiKey, _unitOfWork), + static async (state, cancel) => + await state._unitOfWork.UserRepository.GetUserDtoByAuthKeyAsync(state.apiKey), + CacheOptions, + cancellationToken: Context.RequestAborted).ConfigureAwait(false); + + if (user?.Id == null || string.IsNullOrEmpty(user.Username)) + { + return AuthenticateResult.Fail("Invalid API Key"); + } + + var claims = new List() + { + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(JwtRegisteredClaimNames.Name, user.Username), + new("AuthType", nameof(AuthenticationType.AuthKey)) + }; + + if (user.Roles != null && user.Roles.Any()) + { + claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role))); + } + + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, Scheme.Name); + + return AuthenticateResult.Success(ticket); + } + catch (Exception ex) + { + Logger.LogError(ex, "Auth Key authentication failed"); + return AuthenticateResult.Fail("Auth Key authentication failed"); + } + } + + public static string? ExtractAuthKey(HttpRequest request) + { + // Check query string + if (request.Query.TryGetValue("apiKey", out var apiKeyQuery)) + { + return apiKeyQuery.ToString(); + } + + // Check header + if (request.Headers.TryGetValue(Headers.ApiKey, out var authHeader)) + { + return authHeader.ToString(); + } + + // Check if embedded in route parameters (e.g., /api/somepath/{apiKey}/other) + if (request.RouteValues.TryGetValue("apiKey", out var routeKey)) + { + return routeKey?.ToString(); + } + + return null; + } + + public static string CreateCacheKey(string keyValue) + { + return $"authKey_{keyValue}"; + } +} diff --git a/API/Middleware/UserContextMiddleware.cs b/API/Middleware/UserContextMiddleware.cs index a754be93d..1881c9fba 100644 --- a/API/Middleware/UserContextMiddleware.cs +++ b/API/Middleware/UserContextMiddleware.cs @@ -1,15 +1,12 @@ using System; using System.IdentityModel.Tokens.Jwt; +using System.Linq; using System.Security.Claims; using System.Threading.Tasks; -using API.Data; using API.Entities.Progress; -using API.Extensions; using API.Services; using API.Services.Store; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Logging; namespace API.Middleware; @@ -20,50 +17,33 @@ namespace API.Middleware; /// (JWT, Auth Key, OIDC) and provides a unified IUserContext for downstream components. /// Must run after UseAuthentication() and UseAuthorization(). /// -public class UserContextMiddleware(RequestDelegate next, ILogger logger, HybridCache cache) +public class UserContextMiddleware(RequestDelegate next, ILogger logger) { - private static readonly HybridCacheEntryOptions ApiKeyCacheOptions = new() - { - Expiration = TimeSpan.FromMinutes(15), - LocalCacheExpiration = TimeSpan.FromMinutes(15) - }; - - public async Task InvokeAsync( HttpContext context, - UserContext userContext, // Scoped service - IUnitOfWork unitOfWork) + UserContext userContext) { try { - // Clear any previous context (shouldn't be necessary, but defensive) userContext.Clear(); - // Check if endpoint allows anonymous access - var endpoint = context.GetEndpoint(); - var allowAnonymous = endpoint?.Metadata.GetMetadata() != null; - - // ALWAYS attempt to resolve user identity, regardless of [AllowAnonymous] - var (userId, username, authType) = await ResolveUserIdentityAsync(context, unitOfWork); - - if (userId.HasValue) + if (context.User.Identity?.IsAuthenticated == true) { - userContext.SetUserContext(userId.Value, username!, authType); + var userId = TryGetUserIdFromClaim(context.User, ClaimTypes.NameIdentifier); - logger.LogTrace( - "Resolved user context: UserId={UserId}, AuthType={AuthType}", - userId, authType); - } - else if (!allowAnonymous) - { - // No user resolved on a protected endpoint - this is a problem - // Authorization middleware will handle returning 401/403 - logger.LogWarning("Could not resolve user identity for protected endpoint: {Path}", context.Request.Path.ToString().Sanitize()); - } - else - { - // No user resolved but endpoint allows anonymous - this is fine - logger.LogTrace("No user identity resolved for anonymous endpoint: {Path}", context.Request.Path.ToString().Sanitize()); + var username = context.User.FindFirst(JwtRegisteredClaimNames.Name)?.Value; + + var roles = context.User.FindAll(ClaimTypes.Role) + .Select(c => c.Value) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (userId.HasValue && username != null) + { + var authType = TryParseAuthTypeClaim(context.User) ?? AuthenticationType.Unknown; + + userContext.SetUserContext(userId.Value, username, authType, roles); + } } } catch (Exception ex) @@ -75,122 +55,12 @@ public class UserContextMiddleware(RequestDelegate next, ILogger ResolveUserIdentityAsync( - HttpContext context, - IUnitOfWork unitOfWork) + private static AuthenticationType? TryParseAuthTypeClaim(ClaimsPrincipal user) { - // Priority 1: ALWAYS check for Auth Key first (query string or path parameter) - // Auth Keys work even on [AllowAnonymous] endpoints (like OPDS) - var apiKeyResult = await TryResolveFromAuthKeyAsync(context, unitOfWork); - if (apiKeyResult.userId.HasValue) - { - return apiKeyResult; - } - - - // Priority 3: Check for JWT or OIDC claims - if (context.User.Identity?.IsAuthenticated == true) - { - return ResolveFromClaims(context); - } - - return (null, null, AuthenticationType.Unknown); - } - - /// - /// Tries to resolve auth key and support apiKey from pre-v0.8.6 (switching from apikey -> auth keys) - /// - private async Task<(int? userId, string? username, AuthenticationType authType)> TryResolveFromAuthKeyAsync( - HttpContext context, - IUnitOfWork unitOfWork) - { - string? apiKey = null; - - // Check query string: ?apiKey=xxx - if (context.Request.Query.TryGetValue("apiKey", out var apiKeyQuery)) - { - apiKey = apiKeyQuery.ToString(); - } - - // Check path for OPDS endpoints: /api/opds/{apiKey}/... - if (string.IsNullOrEmpty(apiKey)) - { - var path = context.Request.Path.Value ?? string.Empty; - if (path.Contains("/api/opds/", StringComparison.OrdinalIgnoreCase)) - { - var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); - var opdsIndex = Array.FindIndex(segments, s => - s.Equals("opds", StringComparison.OrdinalIgnoreCase)); - - if (opdsIndex >= 0 && opdsIndex + 1 < segments.Length) - { - apiKey = segments[opdsIndex + 1]; - } - } - } - - // Check if embedded in route parameters (e.g., /api/somepath/{apiKey}/other) - if (string.IsNullOrEmpty(apiKey) && context.Request.RouteValues.TryGetValue("apiKey", out var _)) - { - apiKey = apiKeyQuery.ToString(); - } - - if (string.IsNullOrEmpty(apiKey)) - { - return (null, null, AuthenticationType.Unknown); - } - - try - { - var cacheKey = $"authKey_{apiKey}"; - - var result = await cache.GetOrCreateAsync( - cacheKey, - (apiKey, unitOfWork), - async (state, cancel) => - { - // Auth key will work with legacy apiKey and new auth key - var user = await state.unitOfWork.UserRepository.GetUserDtoByAuthKeyAsync(state.apiKey); - return (user?.Id, user?.Username); - }, - ApiKeyCacheOptions, - cancellationToken: context.RequestAborted); - - if (result is {Id: not null, Username: not null}) - { - logger.LogTrace("Resolved user {UserId} from Auth Key for path {Path}", result.Id, context.Request.Path.ToString().Sanitize()); - - return (result.Id, result.Username, AuthenticationType.AuthKey); - } - - logger.LogWarning("Invalid Auth Key provided for path {Path}", context.Request.Path); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to resolve user from Auth Key"); - } - - return (null, null, AuthenticationType.Unknown); - } - - private static (int? userId, string? username, AuthenticationType authType) ResolveFromClaims(HttpContext context) - { - var claims = context.User; - - // Check if OIDC authentication - if (context.Request.Cookies.ContainsKey(OidcService.CookieName)) - { - var userId = TryGetUserIdFromClaim(claims, ClaimTypes.NameIdentifier); - var username = claims.FindFirst(JwtRegisteredClaimNames.Name)?.Value; - - return (userId, username, AuthenticationType.OIDC); - } - - // JWT authentication - var jwtUserId = TryGetUserIdFromClaim(claims, ClaimTypes.NameIdentifier); - var jwtUsername = claims.FindFirst(JwtRegisteredClaimNames.Name)?.Value; - - return (jwtUserId, jwtUsername, AuthenticationType.JWT); + var authTypeClaim = user.FindFirst("AuthType")?.Value; + return authTypeClaim != null && Enum.TryParse(authTypeClaim, out var authType) + ? authType + : null; } private static int? TryGetUserIdFromClaim(ClaimsPrincipal claims, string claimType) diff --git a/API/Services/Caching/AuthKeyCacheInvalidator.cs b/API/Services/Caching/AuthKeyCacheInvalidator.cs new file mode 100644 index 000000000..45870b4cc --- /dev/null +++ b/API/Services/Caching/AuthKeyCacheInvalidator.cs @@ -0,0 +1,26 @@ +using System.Threading; +using System.Threading.Tasks; +using API.Middleware; +using Microsoft.Extensions.Caching.Hybrid; + +namespace API.Services.Caching; + +public interface IAuthKeyCacheInvalidator +{ + /// + /// Invalidates the cached authentication data for a specific auth key. + /// Call this when a key is rotated or deleted. + /// + /// The actual key value (not the ID) + /// Cancellation token + Task InvalidateAsync(string keyValue, CancellationToken cancellationToken = default); +} + +public class AuthKeyCacheInvalidator(HybridCache cache) : IAuthKeyCacheInvalidator +{ + public async Task InvalidateAsync(string keyValue, CancellationToken cancellationToken = default) + { + var cacheKey = AuthKeyAuthenticationHandler.CreateCacheKey(keyValue); + await cache.RemoveAsync(cacheKey, cancellationToken); + } +} diff --git a/API/Services/ClientDeviceService.cs b/API/Services/ClientDeviceService.cs index 02378ba6a..32c7757b1 100644 --- a/API/Services/ClientDeviceService.cs +++ b/API/Services/ClientDeviceService.cs @@ -451,6 +451,7 @@ public class ClientDeviceService(DataContext context, IMapper mapper, ILogger GetVolumeDisplayName( VolumeDto volume, int userId, EntityDisplayOptions options); + Task GetChapterDisplayName(ChapterDto chapter, int userId, EntityDisplayOptions options); + Task GetChapterDisplayName(Chapter chapter, int userId, EntityDisplayOptions options); + Task GetEntityDisplayName(ChapterDto chapter, int userId, EntityDisplayOptions options); + +} + + +/// +/// Service responsible for generating user-friendly display names for Volumes and Chapters. +/// Centralizes naming logic to avoid exposing internal encodings (-100000). +/// +public class EntityDisplayService(ILocalizationService localizationService, IUnitOfWork unitOfWork) : IEntityDisplayService +{ + /// + /// Generates a user-friendly display name for a Volume. + /// + /// The volume to generate a name for + /// User ID for localization + /// Display options + /// Tuple of (displayName, neededRename) where neededRename indicates if the volume was modified + public async Task<(string displayName, bool neededRename)> GetVolumeDisplayName( VolumeDto volume, int userId, EntityDisplayOptions options) + { + // Handle special volumes - these shouldn't be displayed as regular volumes + if (volume.IsSpecial() || volume.IsLooseLeaf()) + { + return (string.Empty, false); + } + + var libraryType = options.LibraryType; + var neededRename = false; + + // Book/LightNovel treatment - use chapter title as volume name + if (libraryType is LibraryType.Book or LibraryType.LightNovel) + { + var firstChapter = volume.Chapters.FirstOrDefault(); + if (firstChapter == null) + { + return (string.Empty, false); + } + + // Skip special chapters + if (firstChapter.IsSpecial) + { + return (string.Empty, false); + } + + // Use chapter's title name if available + if (!string.IsNullOrEmpty(firstChapter.TitleName)) + { + neededRename = true; + return (firstChapter.TitleName, neededRename); + } + + // Fallback: extract from Range if it's not a loose leaf marker + if (!firstChapter.Range.Equals(Parser.LooseLeafVolume)) + { + var title = Path.GetFileNameWithoutExtension(firstChapter.Range); + if (!string.IsNullOrEmpty(title)) + { + neededRename = true; + var displayName = string.IsNullOrEmpty(volume.Name) + ? title + : $"{volume.Name} - {title}"; + return (displayName, neededRename); + } + } + + return (string.Empty, false); + } + + // Standard volume naming for Comics/Manga + if (options.IncludePrefix) + { + var volumeLabel = options.VolumePrefix + ?? await localizationService.Translate(userId, "volume-num", string.Empty); + neededRename = true; + return ($"{volumeLabel.Trim()} {volume.Name}".Trim(), neededRename); + } + + return (volume.Name, neededRename); + } + + /// + /// Generates a user-friendly display name for a Chapter (DTO). + /// + public async Task GetChapterDisplayName( ChapterDto chapter, int userId, EntityDisplayOptions options) + { + return await GetChapterDisplayNameCore( + chapter.IsSpecial, + chapter.Range, + chapter.Title, + userId, + options); + } + + /// + /// Generates a user-friendly display name for a Chapter (Entity). + /// + public async Task GetChapterDisplayName( Chapter chapter, int userId, EntityDisplayOptions options) + { + return await GetChapterDisplayNameCore( + chapter.IsSpecial, + chapter.Range, + chapter.Title, + userId, + options); + } + + /// + /// Smart method that generates display name for a chapter, automatically detecting if it needs + /// to fetch the volume name instead (for loose leaf volumes in book libraries). + /// This is the recommended method for most scenarios as it handles internal encodings. + /// + /// The chapter to generate a name for + /// User ID for localization + /// Display options + /// User-friendly display name + public async Task GetEntityDisplayName( ChapterDto chapter, int userId, EntityDisplayOptions options) + { + // Detect if this is a loose leaf volume that should be displayed as a volume name + if (chapter.Title == Parser.LooseLeafVolume) + { + var volume = await unitOfWork.VolumeRepository.GetVolumeDtoAsync(chapter.VolumeId, userId); + if (volume != null) + { + var (label, _) = await GetVolumeDisplayName(volume, userId, options); + if (!string.IsNullOrEmpty(label)) + { + return label; + } + } + } + + // Standard chapter display + return await GetChapterDisplayName(chapter, userId, options); + } + + + /// + /// Core implementation for chapter display name generation. + /// + private async Task GetChapterDisplayNameCore( bool isSpecial, string range, string? title, int userId, EntityDisplayOptions options) + { + // Handle special chapters - use cleaned title or fallback to range + if (isSpecial) + { + if (!string.IsNullOrEmpty(title)) + { + return Parser.CleanSpecialTitle(title); + } + // Fallback to cleaned range (filename) + return Parser.CleanSpecialTitle(range); + } + + var libraryType = options.LibraryType; + var useHash = ShouldUseHashSymbol(libraryType, options.ForceHashSymbol); + var hashSpot = useHash ? "#" : string.Empty; + + // Generate base chapter name based on library type + var baseChapter = libraryType switch + { + LibraryType.Book => await localizationService.Translate(userId, "book-num", title ?? range), + LibraryType.LightNovel => await localizationService.Translate(userId, "book-num", range), + LibraryType.Comic => await localizationService.Translate(userId, "issue-num", hashSpot, range), + LibraryType.ComicVine => await localizationService.Translate(userId, "issue-num", hashSpot, range), + LibraryType.Manga => await localizationService.Translate(userId, "chapter-num", range), + LibraryType.Image => await localizationService.Translate(userId, "chapter-num", range), + _ => await localizationService.Translate(userId, "chapter-num", range) + }; + + // Append title suffix if requested and title differs from range + if (options.IncludeTitleSuffix && + !string.IsNullOrEmpty(title) && + libraryType != LibraryType.Book && + title != range) + { + baseChapter += $" - {title}"; + } + + return baseChapter; + } + + /// + /// Determines if hash symbol should be used based on library type and override. + /// + private static bool ShouldUseHashSymbol(LibraryType libraryType, bool? forceHashSymbol) + { + if (forceHashSymbol.HasValue) + { + return forceHashSymbol.Value; + } + + // Smart default: Comics use hash + return libraryType is LibraryType.Comic or LibraryType.ComicVine; + } +} + +/// +/// Options for controlling entity display name generation. +/// +public class EntityDisplayOptions +{ + /// + /// The library type context for the entity. + /// + public LibraryType LibraryType { get; set; } + + /// + /// Whether to append the chapter title as a suffix (e.g., "Chapter 5 - The Beginning"). + /// Default: true + /// + public bool IncludeTitleSuffix { get; set; } = true; + + /// + /// Force inclusion or exclusion of hash symbol (#) for issues. + /// If null, smart default based on library type is used. + /// + public bool? ForceHashSymbol { get; set; } = null; + + /// + /// Whether to include the volume prefix (e.g., "Volume 1" vs "1"). + /// Default: true + /// + public bool IncludePrefix { get; set; } = true; + + /// + /// Pre-translated volume prefix to avoid redundant localization calls. + /// If null, will be fetched via localization service. + /// + public string? VolumePrefix { get; set; } = null; + + /// + /// Creates default options for a given library type. + /// + public static EntityDisplayOptions Default(LibraryType libraryType) => new() + { + LibraryType = libraryType + }; + + /// + /// Creates options with title suffix disabled (useful for compact displays). + /// + public static EntityDisplayOptions WithoutTitleSuffix(LibraryType libraryType) => new() + { + LibraryType = libraryType, + IncludeTitleSuffix = false + }; + + /// + /// Creates options without prefix (e.g., returns "5" instead of "Volume 5"). + /// + public static EntityDisplayOptions WithoutPrefix(LibraryType libraryType) => new() + { + LibraryType = libraryType, + IncludePrefix = false + }; +} diff --git a/API/Services/OidcService.cs b/API/Services/OidcService.cs index cb2264239..b9207b100 100644 --- a/API/Services/OidcService.cs +++ b/API/Services/OidcService.cs @@ -14,8 +14,10 @@ using API.DTOs.Email; using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; +using API.Entities.Progress; using API.Extensions; using API.Helpers.Builders; +using API.Services.Tasks.Metadata; using Hangfire; using Flurl.Http; using Kavita.Common; @@ -65,6 +67,7 @@ public interface IOidcService /// It is registered as a singleton only if oidc is enabled. So must be nullable and optional public class OidcService(ILogger logger, UserManager userManager, IUnitOfWork unitOfWork, IAccountService accountService, IEmailService emailService, + ICoverDbService coverDbService, [FromServices] ConfigurationManager? configurationManager = null): IOidcService { public const string LibraryAccessPrefix = "library-"; @@ -73,6 +76,7 @@ public class OidcService(ILogger logger, UserManager userM public const string RefreshToken = "refresh_token"; public const string IdToken = "id_token"; public const string ExpiresAt = "expires_at"; + /// The name of the Auth Cookie set by .NET public const string CookieName = ".AspNetCore.Cookies"; public static readonly List DefaultScopes = ["openid", "profile", "offline_access", "roles", "email"]; @@ -227,7 +231,7 @@ public class OidcService(ILogger logger, UserManager userM { return await NewUserFromOpenIdConnect(request, settings, principal, oidcId); } - catch (KavitaException e) + catch (KavitaException) { throw; } @@ -380,19 +384,22 @@ public class OidcService(ILogger logger, UserManager userM { if (!settings.SyncUserSettings || user.IdentityProvider != IdentityProvider.OpenIdConnect) return; - // Never sync the default user var defaultAdminUser = await unitOfWork.UserRepository.GetDefaultAdminUser(); - if (defaultAdminUser.Id == user.Id) return; logger.LogDebug("Syncing user {UserId} from OIDC", user.Id); try { - await SyncEmail(request, settings, claimsPrincipal, user); - await SyncUsername(claimsPrincipal, user); - await SyncRoles(settings, claimsPrincipal, user); - await SyncLibraries(settings, claimsPrincipal, user); - await SyncAgeRestriction(settings, claimsPrincipal, user); + if (user.Id != defaultAdminUser.Id) + { + await SyncEmail(request, settings, claimsPrincipal, user); + await SyncUsername(claimsPrincipal, user); + await SyncRoles(settings, claimsPrincipal, user); + await SyncLibraries(settings, claimsPrincipal, user); + await SyncAgeRestriction(settings, claimsPrincipal, user); + } + + SyncExtras(claimsPrincipal, user); if (unitOfWork.HasChanges()) { @@ -407,6 +414,20 @@ public class OidcService(ILogger logger, UserManager userM } } + private void SyncExtras(ClaimsPrincipal claimsPrincipal, AppUser user) + { + var picture = claimsPrincipal.FindFirst(JwtRegisteredClaimNames.Picture)?.Value; + + // Only sync if the user has no image, I know this is no true sync as changing it in your idp + // will require action on Kavita. But it's less effort than saving if the user has set it themselves + // Will just need to be documented on the wiki. + if (!string.IsNullOrEmpty(picture) && string.IsNullOrEmpty(user.CoverImage)) + { + // Run in background to not block http thread, pass id to Hangfire doesn't kill itself + BackgroundJob.Enqueue(() => coverDbService.SetUserCoverByUrl(user.Id, picture, false)); + } + } + private async Task SyncEmail(HttpRequest request, OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user) { var email = claimsPrincipal.FindFirstValue(ClaimTypes.Email); @@ -503,10 +524,16 @@ public class OidcService(ILogger logger, UserManager userM .Where(s => rolesFromToken.Contains(s, StringComparer.OrdinalIgnoreCase)) .ToList(); + // Ensure that Admin Role and ReadOnly aren't both selected + if (roles.Contains(PolicyConstants.AdminRole)) + { + roles = roles.Where(r => r != PolicyConstants.ReadOnlyRole).ToList(); + } + logger.LogDebug("Syncing access roles for user {UserId}, found roles {Roles}", user.Id, roles); var errors = (await accountService.UpdateRolesForUser(user, roles)).ToList(); - if (errors.Any()) + if (errors.Count != 0) { logger.LogError("Failed to sync roles {Errors}", errors.Select(x => x.Description).ToList()); throw new KavitaException("errors.oidc.syncing-user"); @@ -658,6 +685,7 @@ public class OidcService(ILogger logger, UserManager userM new(ClaimTypes.NameIdentifier, user.Id.ToString()), new(JwtRegisteredClaimNames.Name, user.UserName ?? string.Empty), new(ClaimTypes.Name, user.UserName ?? string.Empty), + new("AuthType", nameof(AuthenticationType.OIDC)) }; var userManager = services.GetRequiredService>(); diff --git a/API/Services/OpdsService.cs b/API/Services/OpdsService.cs index 3aee03a79..c36a9dbe7 100644 --- a/API/Services/OpdsService.cs +++ b/API/Services/OpdsService.cs @@ -728,7 +728,7 @@ public class OpdsService : IOpdsService throw new OpdsException(await _localizationService.Translate(userId, "series-doesnt-exist")); } - var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters); + var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(volumeId, VolumeIncludes.Chapters); if (volume == null) { throw new OpdsException(await _localizationService.Translate(userId, "volume-doesnt-exist")); @@ -782,7 +782,7 @@ public class OpdsService : IOpdsService throw new OpdsException(await _localizationService.Translate(userId, "chapter-doesnt-exist")); } - var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId); + var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(volumeId); var chapterName = await _seriesService.FormatChapterName(userId, libraryType); var feed = CreateFeed( $"{series.Name} - Volume {volume!.Name} - {chapterName} {chapterId}", diff --git a/API/Services/Plus/ExternalMetadataService.cs b/API/Services/Plus/ExternalMetadataService.cs index 5bcda7b27..ed4d8a9f7 100644 --- a/API/Services/Plus/ExternalMetadataService.cs +++ b/API/Services/Plus/ExternalMetadataService.cs @@ -520,9 +520,10 @@ public class ExternalMetadataService : IExternalMetadataService externalSeriesMetadata.AverageExternalRating = extRatings.Count != 0 ? (int) extRatings .Average(r => r.AverageScore) : 0; - if (result.MalId.HasValue) externalSeriesMetadata.MalId = result.MalId.Value; - if (result.AniListId.HasValue) externalSeriesMetadata.AniListId = result.AniListId.Value; - if (result.CbrId.HasValue) externalSeriesMetadata.CbrId = result.CbrId.Value; + // prefer what was passed in (manual match), fall back to what K+ returned + externalSeriesMetadata.MalId = data.MalId ?? result.MalId ?? 0; + externalSeriesMetadata.AniListId = data.AniListId ?? result.AniListId ?? 0; + externalSeriesMetadata.CbrId = data.CbrId ?? result.CbrId ?? 0; // If there is metadata and the user has metadata download turned on var madeMetadataModification = false; diff --git a/API/Services/Reading/ReaderService.cs b/API/Services/Reading/ReaderService.cs index d01d09055..71a01c121 100644 --- a/API/Services/Reading/ReaderService.cs +++ b/API/Services/Reading/ReaderService.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using API.Comparators; using API.Data; +using API.Data.Migrations; using API.Data.Repositories; using API.DTOs; using API.DTOs.Progress; @@ -12,6 +13,7 @@ using API.DTOs.Reader; using API.Entities; using API.Entities.Enums; using API.Entities.Progress; +using API.Entities.User; using API.Extensions; using API.Services.Plus; using API.Services.Tasks.Scanner.Parser; @@ -38,14 +40,16 @@ public interface IReaderService Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber); IDictionary GetPairs(IEnumerable dimensions); Task GetThumbnail(Chapter chapter, int pageNum, IEnumerable cachedImages); + Task CheckSeriesForReRead(int userId, int seriesId); + Task CheckVolumeForReRead(int userId, int volumeId, int seriesId, int libraryId); + Task CheckChapterForReRead(int userId, int chapterId, int seriesId, int libraryId); } public class ReaderService(IUnitOfWork unitOfWork, ILogger logger, IEventHub eventHub, IImageService imageService, IDirectoryService directoryService, IScrobblingService scrobblingService, IReadingSessionService readingSessionService, - IClientInfoAccessor clientInfoAccessor) + IClientInfoAccessor clientInfoAccessor, ISeriesService seriesService, IEntityDisplayService entityDisplayService) : IReaderService { - private readonly IClientInfoAccessor _clientInfoAccessor = clientInfoAccessor; private readonly ChapterSortComparerDefaultLast _chapterSortComparerDefaultLast = ChapterSortComparerDefaultLast.Default; private readonly ChapterSortComparerDefaultFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerDefaultFirst.Default; private readonly ChapterSortComparerSpecialsLast _chapterSortComparerSpecialsLast = ChapterSortComparerSpecialsLast.Default; @@ -532,62 +536,28 @@ public class ReaderService(IUnitOfWork unitOfWork, ILogger logger /// public async Task GetContinuePoint(int seriesId, int userId) { - var volumes = (await unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList(); - - var anyUserProgress = - await unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId); - - if (!anyUserProgress) + var hasProgress = await unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId); + if (!hasProgress) { - // I think i need a way to sort volumes last - volumes = volumes.OrderBy(v => v.MinNumber, _chapterSortComparerSpecialsLast).ToList(); - - // Check if we have a non-loose leaf volume - var nonLooseLeafNonSpecialVolume = volumes.Find(v => !v.IsLooseLeaf() && !v.IsSpecial()); - if (nonLooseLeafNonSpecialVolume != null) - { - return nonLooseLeafNonSpecialVolume.Chapters.MinBy(c => c.SortOrder); - } - - // We only have a loose leaf or Special left - - var chapters = volumes.First(v => v.IsLooseLeaf() || v.IsSpecial()).Chapters - .OrderBy(c => c.SortOrder) - .ToList(); - - // If there are specials, then return the first Non-special - if (chapters.Exists(c => c.IsSpecial)) - { - var firstChapter = chapters.Find(c => !c.IsSpecial); - if (firstChapter == null) - { - // If there is no non-special chapter, then return first chapter - return chapters[0]; - } - - return firstChapter; - } - // Else use normal logic - return chapters[0]; + // Get first chapter only + return await unitOfWork.ChapterRepository.GetFirstChapterForSeriesAsync(seriesId, userId); } - // Loop through all chapters that are not in volume 0 - var volumeChapters = volumes - .WhereNotLooseLeaf() - .SelectMany(v => v.Chapters) + var currentlyReading = await unitOfWork.ChapterRepository.GetCurrentlyReadingChapterAsync(seriesId, userId); + + if (currentlyReading != null) + { + return currentlyReading; + } + + var volumes = (await unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList(); + + var allChapters = volumes + .OrderBy(v => v.MinNumber, _chapterSortComparerDefaultLast) + .SelectMany(v => v.Chapters.OrderBy(c => c.SortOrder)) .ToList(); - // NOTE: If volume 1 has chapter 1 and volume 2 is just chapter 0 due to being a full volume file, then this fails - // If there are any volumes that have progress, return those. If not, move on. - var currentlyReadingChapter = volumeChapters - .OrderBy(c => c.MinNumber, _chapterSortComparerDefaultLast) - .FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages && chapter.PagesRead > 0); - if (currentlyReadingChapter != null) return currentlyReadingChapter; - - // Order with volume 0 last so we prefer the natural order - return FindNextReadingChapter(volumes.OrderBy(v => v.MinNumber, _chapterSortComparerDefaultLast) - .SelectMany(v => v.Chapters.OrderBy(c => c.SortOrder)) - .ToList()); + return FindNextReadingChapter(allChapters); } private static ChapterDto FindNextReadingChapter(IList volumeChapters) @@ -779,6 +749,190 @@ public class ReaderService(IUnitOfWork unitOfWork, ILogger logger } } + public async Task CheckSeriesForReRead(int userId, int seriesId) + { + var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library); + if (series == null) return RereadDto.Dont(); + + var libraryType = series.Library.Type; + var options = EntityDisplayOptions.Default(libraryType); + var continuePoint = await GetContinuePoint(seriesId, userId); + var lastProgress = await unitOfWork.AppUserProgressRepository.GetLatestProgressForSeries(seriesId, userId); + var continuePointLabel = await entityDisplayService.GetEntityDisplayName(continuePoint, userId, options); + + if (lastProgress == null || !await unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId)) + { + return new RereadDto + { + ShouldPrompt = false, + ChapterOnContinue = new RereadChapterDto(series.LibraryId, seriesId, continuePoint.Id, continuePointLabel, continuePoint.Format) + }; + } + + var userPreferences = await unitOfWork.UserRepository.GetPreferencesForUser(userId); + + return await BuildRereadDto( + userId, + userPreferences, + series.LibraryId, + seriesId, + libraryType, + continuePoint, + continuePointLabel, + lastProgress.Value, + getPrevChapter: async () => + { + var chapterId = await GetPrevChapterIdAsync(seriesId, continuePoint.VolumeId, continuePoint.Id, userId); + if (chapterId == -1) return null; + + return await unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, userId); + }, + isValidPrevChapter: prevChapter => prevChapter != null + ); + } + + public async Task CheckVolumeForReRead(int userId, int volumeId, int seriesId, int libraryId) + { + var userPreferences = await unitOfWork.UserRepository.GetPreferencesForUser(userId); + + var volume = await unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId); + if (volume == null) return RereadDto.Dont(); + + var libraryType = await unitOfWork.LibraryRepository.GetLibraryTypeAsync(libraryId); + var continuePoint = FindNextReadingChapter([.. volume.Chapters]); + var options = EntityDisplayOptions.Default(libraryType); + var continuePointLabel = await entityDisplayService.GetEntityDisplayName(continuePoint, userId, options); + + var lastProgress = await unitOfWork.AppUserProgressRepository.GetLatestProgressForVolume(volumeId, userId); + + // Check if there's no progress on the volume + if (lastProgress == null || volume.PagesRead == 0) + { + return new RereadDto + { + ShouldPrompt = false, + ChapterOnContinue = new RereadChapterDto(libraryId, seriesId, continuePoint.Id, continuePointLabel, continuePoint.Format) + }; + } + + return await BuildRereadDto( + userId, + userPreferences, + libraryId, + seriesId, + libraryType, + continuePoint, + continuePointLabel, + lastProgress.Value, + getPrevChapter: async () => + { + var chapterId = await GetPrevChapterIdAsync(seriesId, continuePoint.VolumeId, continuePoint.Id, userId); + if (chapterId == -1) return null; + + return await unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, userId); + }, + isValidPrevChapter: prevChapter => prevChapter != null && prevChapter.VolumeId == volume.Id + ); + } + + private async Task BuildRereadDto( + int userId, + AppUserPreferences userPreferences, + int libraryId, + int seriesId, + LibraryType libraryType, + ChapterDto continuePoint, + string continuePointLabel, + DateTime lastProgress, + Func> getPrevChapter, + Func isValidPrevChapter) + { + var daysSinceLastProgress = (DateTime.UtcNow - lastProgress).Days; + var reReadForTime = daysSinceLastProgress != 0 && daysSinceLastProgress > userPreferences.PromptForRereadsAfter; + + // Next up chapter has progress, re-read if it's fully read or long ago + if (continuePoint.PagesRead > 0) + { + var reReadChapterDto = new RereadChapterDto(libraryId, seriesId, continuePoint.Id, continuePointLabel, continuePoint.Format); + + return new RereadDto + { + ShouldPrompt = continuePoint.PagesRead >= continuePoint.Pages || reReadForTime, + TimePrompt = continuePoint.PagesRead < continuePoint.Pages, + DaysSinceLastRead = daysSinceLastProgress, + ChapterOnContinue = reReadChapterDto, + ChapterOnReread = reReadChapterDto + }; + } + + var prevChapter = await getPrevChapter(); + + // There is no valid previous chapter, use continue point for re-read + if (!isValidPrevChapter(prevChapter)) + { + var reReadChapterDto = new RereadChapterDto(libraryId, seriesId, continuePoint.Id, continuePointLabel, continuePoint.Format); + + return new RereadDto + { + ShouldPrompt = continuePoint.PagesRead >= continuePoint.Pages || reReadForTime, + TimePrompt = continuePoint.PagesRead < continuePoint.Pages, + DaysSinceLastRead = daysSinceLastProgress, + ChapterOnContinue = reReadChapterDto, + ChapterOnReread = reReadChapterDto + }; + } + + // Prompt if it's been a while and might need a refresher (start with the prev chapter) + var prevChapterLabel = await seriesService.FormatChapterTitle(userId, prevChapter!, libraryType); + + return new RereadDto + { + ShouldPrompt = reReadForTime, + TimePrompt = true, + DaysSinceLastRead = daysSinceLastProgress, + ChapterOnContinue = new RereadChapterDto(libraryId, seriesId, continuePoint.Id, continuePointLabel, continuePoint.Format), + ChapterOnReread = new RereadChapterDto(libraryId, seriesId, prevChapter!.Id, prevChapterLabel, prevChapter.Format) + }; + } + + public async Task CheckChapterForReRead(int userId, int chapterId, int seriesId, int libraryId) + { + var userPreferences = await unitOfWork.UserRepository.GetPreferencesForUser(userId); + + var chapter = await unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, userId); + if (chapter == null) return RereadDto.Dont(); + + var lastProgress = await unitOfWork.AppUserProgressRepository.GetLatestProgressForChapter(chapterId, userId); + + var libraryType = await unitOfWork.LibraryRepository.GetLibraryTypeAsync(libraryId); + var options = EntityDisplayOptions.Default(libraryType); + var chapterLabel = await entityDisplayService.GetEntityDisplayName(chapter, userId, options); + var reReadChapter = new RereadChapterDto(libraryId, seriesId, chapterId, chapterLabel, chapter.Format); + + // No progress, read it + if (lastProgress == null || chapter.PagesRead == 0) + { + return new RereadDto + { + ShouldPrompt = false, + ChapterOnContinue = reReadChapter, + }; + } + + var daysSinceLastProgress = (DateTime.UtcNow - lastProgress.Value).Days; + var reReadForTime = daysSinceLastProgress != 0 && daysSinceLastProgress > userPreferences.PromptForRereadsAfter; + + // Prompt if fully read or long ago + return new RereadDto + { + ShouldPrompt = chapter.PagesRead >= chapter.Pages || reReadForTime, + TimePrompt = chapter.PagesRead < chapter.Pages, + DaysSinceLastRead = daysSinceLastProgress, + ChapterOnContinue = reReadChapter, + ChapterOnReread = reReadChapter + }; + } + /// /// Formats a Chapter name based on the library it's in /// diff --git a/API/Services/StatisticService.cs b/API/Services/StatisticService.cs index cf633c5af..b58f0ca89 100644 --- a/API/Services/StatisticService.cs +++ b/API/Services/StatisticService.cs @@ -91,6 +91,7 @@ public class StatisticService(ILogger logger, DataContext cont .Select(p => (int?) p.PagesRead) .SumAsync() ?? 0; + // TODO: this needs to use AppUserReadingSessions var timeSpentReading = await TimeSpentReadingForUsersAsync(new List() {userId}, libraryIds); var totalWordsRead = (long) Math.Round(await context.AppUserProgresses @@ -106,9 +107,9 @@ public class StatisticService(ILogger logger, DataContext cont .Where(p => p.PagesRead >= context.Chapter.Single(c => c.Id == p.ChapterId).Pages) .CountAsync(); - var lastActive = await context.AppUserProgresses + var lastActive = await context.AppUserReadingSession .Where(p => p.AppUserId == userId) - .Select(p => p.LastModified) + .Select(u => u.EndTimeUtc) .DefaultIfEmpty() .MaxAsync(); @@ -133,6 +134,7 @@ public class StatisticService(ILogger logger, DataContext cont .ToListAsync(); + // TODO: Move this to ReadingSession // New solution. Calculate total hours then divide by number of weeks from time account was created (or min reading event) till now var averageReadingTimePerWeek = await context.AppUserProgresses .Where(p => p.AppUserId == userId) @@ -167,15 +169,13 @@ public class StatisticService(ILogger logger, DataContext cont } - - return new UserReadStatistics() { TotalPagesRead = totalPagesRead, TotalWordsRead = totalWordsRead, TimeSpentReading = timeSpentReading, ChaptersRead = chaptersRead, - LastActive = lastActive, + LastActiveUtc = lastActive, PercentReadPerLibrary = totalProgressByLibrary, AvgHoursPerWeekSpentReading = averageReadingTimePerWeek }; @@ -673,7 +673,6 @@ public class StatisticService(ILogger logger, DataContext cont if (sessionActivityData.Count == 0) return result; - // Group and aggregate in memory (minimal data already fetched) var dailyStats = sessionActivityData .GroupBy(x => x.SessionDate) .Select(dayGroup => new @@ -949,7 +948,7 @@ public class StatisticService(ILogger logger, DataContext cont var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId); var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId); - var readsPerTag = await context.AppUserReadingSessionActivityData + var readsPerTagTask = context.AppUserReadingSessionActivityData .ApplyStatsFilter(filter, userId, socialPreferences, requestingUser) .GroupBy(d => d.SeriesId) .Select(d => new @@ -989,32 +988,34 @@ public class StatisticService(ILogger logger, DataContext cont .Take(10) .ToListAsync(); - var totalMissingData = await context.AppUserReadingSessionActivityData + var totalMissingDataTask = context.AppUserReadingSessionActivityData .ApplyStatsFilter(filter, userId, socialPreferences, requestingUser) .Select(p => p.SeriesId) .Distinct() .Join(context.SeriesMetadata, p => p, sm => sm.SeriesId, (g, m) => m.Tags) .CountAsync(g => !g.Any()); - var totalReads = await context.AppUserReadingSessionActivityData + var totalReadsTask = context.AppUserReadingSessionActivityData .ApplyStatsFilter(filter, userId, socialPreferences, requestingUser) .Select(p => p.SeriesId) .Distinct() .CountAsync(); - var totalReadTags = await context.AppUserReadingSessionActivityData + var totalReadTagsTask = context.AppUserReadingSessionActivityData .ApplyStatsFilter(filter, userId, socialPreferences, requestingUser) .Join(context.Chapter, p => p.ChapterId, c => c.Id, (p, c) => c.Tags) .SelectMany(g => g.Select(gg => gg.NormalizedTitle)) .Distinct() .CountAsync(); + await Task.WhenAll(readsPerTagTask, totalMissingDataTask, totalReadsTask, totalReadTagsTask); + return new BreakDownDto() { - Data = readsPerTag, - Missing = totalMissingData, - Total = totalReads, - TotalOptions = totalReadTags, + Data = await readsPerTagTask, + Missing = await totalMissingDataTask, + Total = await totalReadsTask, + TotalOptions = await totalReadTagsTask, }; } @@ -1146,7 +1147,7 @@ public class StatisticService(ILogger logger, DataContext cont var hourStats = sessions .SelectMany(session => { - var hours = new List<(int hour, TimeSpan timeSpent)>(); + var hours = new List<(DateOnly day, int hour, TimeSpan timeSpent)>(); var current = session.StartTime; while (current < session.EndTime) @@ -1156,24 +1157,31 @@ public class StatisticService(ILogger logger, DataContext cont var endOfPeriod = new[] { hourEnd, sessionEnd }.Min(); var timeSpent = endOfPeriod - current; - hours.Add((current.Hour, timeSpent)); + hours.Add((DateOnly.FromDateTime(current), current.Hour, timeSpent)); current = endOfPeriod; } return hours; }) + .GroupBy(x => new { x.day, x.hour }) + .Select(g => new + { + g.Key.day, + g.Key.hour, + totalTimeSpent = g.Sum(x => x.timeSpent.TotalMinutes) + }) .GroupBy(x => x.hour) .ToDictionary( g => g.Key, - g => (long)g.Average(x => x.timeSpent.TotalMinutes) + g => g.Average(x => x.totalTimeSpent) ); var data = Enumerable.Range(0, 24) .Select(hour => new StatCount { Value = hour, - Count = hourStats.GetValueOrDefault(hour, 0), + Count = (long) Math.Ceiling(hourStats.TryGetValue(hour, out var value) ? value : 0), }) .ToList(); @@ -1349,9 +1357,13 @@ public class StatisticService(ILogger logger, DataContext cont var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId); var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId); - // It makes no sense to filter this in time. Remove them - filter.StartDate = null; - filter.EndDate = null; + // It makes no sense to filter this in by month etc. Trim to year + filter.StartDate = filter.StartDate.HasValue + ? new DateTime(filter.StartDate.Value.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc) + : null; + filter.EndDate = filter.EndDate.HasValue + ? new DateTime(filter.EndDate.Value.Year, 12, 31, 23, 59, 59, 0, 0, DateTimeKind.Utc) + : null; return await context.AppUserReadingSessionActivityData .ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, isAggregate: true) diff --git a/API/Services/Store/CustomTicketStore.cs b/API/Services/Store/CustomTicketStore.cs index 94453bc2d..13a57af78 100644 --- a/API/Services/Store/CustomTicketStore.cs +++ b/API/Services/Store/CustomTicketStore.cs @@ -1,9 +1,10 @@ using System; +using System.Security.Claims; using System.Security.Cryptography; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Caching.Distributed; namespace API.Services.Store; @@ -13,8 +14,7 @@ namespace API.Services.Store; /// due the large header size. Instead, the key is used. /// /// -/// Note that this store is in memory, so OIDC authenticated users are logged out after restart -public class CustomTicketStore(IMemoryCache cache): ITicketStore +public class CustomTicketStore(IDistributedCache cache, TicketSerializer ticketSerializer): ITicketStore { public async Task StoreAsync(AuthenticationTicket ticket) @@ -31,11 +31,7 @@ public class CustomTicketStore(IMemoryCache cache): ITicketStore public Task RenewAsync(string key, AuthenticationTicket ticket) { - var options = new MemoryCacheEntryOptions - { - Priority = CacheItemPriority.NeverRemove, - Size = 1, - }; + var options = new DistributedCacheEntryOptions(); var expiresUtc = ticket.Properties.ExpiresUtc; if (expiresUtc.HasValue) @@ -47,20 +43,31 @@ public class CustomTicketStore(IMemoryCache cache): ITicketStore options.SlidingExpiration = TimeSpan.FromDays(7); } - cache.Set(key, ticket, options); - - return Task.CompletedTask; + return cache.SetAsync(key, ticketSerializer.Serialize(ticket), options); } - public Task RetrieveAsync(string key) + public async Task RetrieveAsync(string key) { - return Task.FromResult(cache.Get(key)); + var bytes = await cache.GetAsync(key); + if (bytes == null) return CreateFailureTicket(); + + return ticketSerializer.Deserialize(bytes); } public Task RemoveAsync(string key) { - cache.Remove(key); + return cache.RemoveAsync(key); + } - return Task.CompletedTask; + private static AuthenticationTicket CreateFailureTicket() + { + var identity = new ClaimsIdentity(); + var principal = new ClaimsPrincipal(identity); + var properties = new AuthenticationProperties + { + ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(-1), // Already expired + }; + + return new AuthenticationTicket(principal, properties, "Cookies"); } } diff --git a/API/Services/Store/UserContext.cs b/API/Services/Store/UserContext.cs index 894f6bdda..c9a4b1dfc 100644 --- a/API/Services/Store/UserContext.cs +++ b/API/Services/Store/UserContext.cs @@ -1,4 +1,7 @@ -using API.Entities.Progress; +using System; +using System.Collections.Generic; +using System.Linq; +using API.Entities.Progress; using Kavita.Common; namespace API.Services.Store; @@ -24,16 +27,23 @@ public interface IUserContext /// /// Warning! Username's can contain .. and /, do not use folders or filenames explicitly with the Username string? GetUsername(); - + /// + /// The Roles associated with the Authenticated user + /// + IReadOnlyList Roles { get; } + /// + /// Returns true if the current user is authenticated. + /// + bool IsAuthenticated { get; } /// /// Gets the authentication method used (JWT, Auth Key, OIDC). /// AuthenticationType GetAuthenticationType(); - /// - /// Returns true if the current user is authenticated. - /// - bool IsAuthenticated { get; } + + bool HasRole(string role); + bool HasAnyRole(params string[] roles); + bool HasAllRoles(params string[] roles); } public class UserContext : IUserContext @@ -41,6 +51,7 @@ public class UserContext : IUserContext private int? _userId; private string? _username; private AuthenticationType _authType; + private List _roles = new(); public int? GetUserId() => _userId; @@ -54,14 +65,16 @@ public class UserContext : IUserContext public AuthenticationType GetAuthenticationType() => _authType; public bool IsAuthenticated { get; private set; } + public IReadOnlyList Roles => _roles.AsReadOnly(); // Internal method used by middleware to set context - internal void SetUserContext(int userId, string username, AuthenticationType authType) + internal void SetUserContext(int userId, string username, AuthenticationType authType, IEnumerable roles) { _userId = userId; _username = username; _authType = authType; IsAuthenticated = true; + _roles = roles?.ToList() ?? []; } internal void Clear() @@ -70,5 +83,21 @@ public class UserContext : IUserContext _username = null; _authType = AuthenticationType.Unknown; IsAuthenticated = false; + _roles.Clear(); + } + + public bool HasRole(string role) + { + return _roles.Any(r => r.Equals(role, StringComparison.OrdinalIgnoreCase)); + } + + public bool HasAnyRole(params string[] roles) + { + return roles.Any(HasRole); + } + + public bool HasAllRoles(params string[] roles) + { + return roles.All(HasRole); } } diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 03aba0d75..d2a967957 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -9,6 +9,7 @@ using API.Entities.Enums; using API.Entities.Enums.User; using API.Helpers; using API.Helpers.Converters; +using API.Services.Caching; using API.Services.Plus; using API.Services.Reading; using API.Services.Tasks; @@ -68,6 +69,7 @@ public class TaskScheduler : ITaskScheduler private readonly IWantToReadSyncService _wantToReadSyncService; private readonly IEventHub _eventHub; private readonly IEmailService _emailService; + private readonly IAuthKeyCacheInvalidator _authKeyCacheInvalidator; public static BackgroundJobServer Client => new (); public const string ScanQueue = "scan"; @@ -115,7 +117,8 @@ public class TaskScheduler : ITaskScheduler IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService, IMediaConversionService mediaConversionService, IScrobblingService scrobblingService, ILicenseService licenseService, IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService, - IWantToReadSyncService wantToReadSyncService, IEventHub eventHub, IEmailService emailService) + IWantToReadSyncService wantToReadSyncService, IEventHub eventHub, IEmailService emailService, + IAuthKeyCacheInvalidator authKeyCacheInvalidator) { _cacheService = cacheService; _logger = logger; @@ -137,6 +140,7 @@ public class TaskScheduler : ITaskScheduler _wantToReadSyncService = wantToReadSyncService; _eventHub = eventHub; _emailService = emailService; + _authKeyCacheInvalidator = authKeyCacheInvalidator; _defaultRetryPolicy = Policy .Handle() @@ -255,19 +259,22 @@ public class TaskScheduler : ITaskScheduler LicenseService.Cron, RecurringJobOptions); // KavitaPlus Scrobbling (every hour) - randomise minutes to spread requests out for K+ + var randomMinute = Rnd.Next(0, 60); RecurringJob.AddOrUpdate(ProcessScrobblingEventsId, () => _scrobblingService.ProcessUpdatesSinceLastSync(), - Cron.Hourly(Rnd.Next(0, 60)), RecurringJobOptions); + Cron.Hourly(randomMinute), RecurringJobOptions); RecurringJob.AddOrUpdate(ProcessProcessedScrobblingEventsId, () => _scrobblingService.ClearProcessedEvents(), Cron.Daily, RecurringJobOptions); // Backfilling/Freshening Reviews/Rating/Recommendations + var randomKPlusBackfill = Rnd.Next(1, 5); RecurringJob.AddOrUpdate(KavitaPlusDataRefreshId, - () => _externalMetadataService.FetchExternalDataTask(), Cron.Daily(Rnd.Next(1, 5)), + () => _externalMetadataService.FetchExternalDataTask(), Cron.Daily(randomKPlusBackfill), RecurringJobOptions); // This shouldn't be so close to fetching data due to Rate limit concerns + var randomKPlusStackSync = Rnd.Next(6, 10); RecurringJob.AddOrUpdate(KavitaPlusStackSyncId, - () => _smartCollectionSyncService.Sync(), Cron.Daily(Rnd.Next(6, 10)), + () => _smartCollectionSyncService.Sync(), Cron.Daily(randomKPlusStackSync), RecurringJobOptions); RecurringJob.AddOrUpdate(KavitaPlusWantToReadSyncId, @@ -275,6 +282,7 @@ public class TaskScheduler : ITaskScheduler RecurringJobOptions); } + /// /// Removes any Kavita+ Recurring Jobs /// @@ -298,11 +306,14 @@ public class TaskScheduler : ITaskScheduler if (!allowStatCollection) { _logger.LogDebug("User has opted out of stat collection, not registering tasks"); + RecurringJob.RemoveIfExists(ReportStatsTaskId); return; } - _logger.LogDebug("Scheduling stat collection daily"); - RecurringJob.AddOrUpdate(ReportStatsTaskId, () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), RecurringJobOptions); + var hour = Rnd.Next(0, 22); + _logger.LogDebug("Scheduling stat collection daily at {Hour}:00", hour); + + RecurringJob.AddOrUpdate(ReportStatsTaskId, () => _statsService.Send(), Cron.Daily(hour), RecurringJobOptions); } @@ -559,13 +570,13 @@ public class TaskScheduler : ITaskScheduler // Check if key is expired var expiredKeys = user.AuthKeys - .Where(k => k is {Provider: AuthKeyProvider.User, ExpiresAtUtc: not null} && k.ExpiresAtUtc >= DateTime.UtcNow) + .Where(k => k is {Provider: AuthKeyProvider.User, ExpiresAtUtc: not null} && k.ExpiresAtUtc <= DateTime.UtcNow) .ToList(); var expiringSoonKeys = user.AuthKeys - .Where(k => k is {Provider: AuthKeyProvider.User, ExpiresAtUtc: not null} && k.ExpiresAtUtc >= DateTime.UtcNow.Subtract(TimeSpan.FromDays(7))) + .Where(k => k is {Provider: AuthKeyProvider.User, ExpiresAtUtc: not null} && k.ExpiresAtUtc <= DateTime.UtcNow.Subtract(TimeSpan.FromDays(7))) .ToList(); - if (expiringSoonKeys.Any()) + if (expiringSoonKeys.Count != 0) { var expiringSoonLatestDate = expiringSoonKeys.Max(k => k.ExpiresAtUtc); if (await ShouldSendAuthKeyExpirationReminder(user.Id, expiringSoonLatestDate!.Value, EmailService.AuthKeyExpiringSoonTemplate)) @@ -575,14 +586,19 @@ public class TaskScheduler : ITaskScheduler } - if (expiredKeys.Any()) + if (expiredKeys.Count != 0) { - var expiringSoonLatestDate = expiringSoonKeys.Max(k => k.ExpiresAtUtc); - if (await ShouldSendAuthKeyExpirationReminder(user.Id, expiringSoonLatestDate!.Value, + var expiredLatestDate = expiredKeys.Max(k => k.ExpiresAtUtc); + if (await ShouldSendAuthKeyExpirationReminder(user.Id, expiredLatestDate!.Value, EmailService.AuthKeyExpiredTemplate)) { await _emailService.SendAuthKeyExpiredEmail(user.Id, expiredKeys); } + + foreach (var expiredKey in expiredKeys) + { + await _authKeyCacheInvalidator.InvalidateAsync(expiredKey.Key); + } } } } @@ -660,16 +676,13 @@ public class TaskScheduler : ITaskScheduler if (ret) return true; - if (checkRunningJobs) - { - var runningJobs = JobStorage.Current.GetMonitoringApi().ProcessingJobs(0, int.MaxValue); - return runningJobs.Exists(j => - j.Value.Job.Method.DeclaringType != null && j.Value.Job.Args.SequenceEqual(args) && - j.Value.Job.Method.Name.Equals(methodName) && - j.Value.Job.Method.DeclaringType.Name.Equals(className)); - } + if (!checkRunningJobs) return false; - return false; + var runningJobs = JobStorage.Current.GetMonitoringApi().ProcessingJobs(0, int.MaxValue); + return runningJobs.Exists(j => + j.Value.Job.Method.DeclaringType != null && j.Value.Job.Args.SequenceEqual(args) && + j.Value.Job.Method.Name.Equals(methodName) && + j.Value.Job.Method.DeclaringType.Name.Equals(className)); } diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs index 5002d97c7..af4310116 100644 --- a/API/Services/Tasks/Metadata/CoverDbService.cs +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -33,6 +33,7 @@ public interface ICoverDbService Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false, bool chooseBetterImage = true); Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false); Task SetChapterCoverByUrl(Chapter chapter, string url, bool fromBase64 = true, bool chooseBetterImage = false); + Task SetUserCoverByUrl(int userId, string url, bool fromBase64 = true, bool chooseBetterImage = false); Task SetUserCoverByUrl(AppUser user, string url, bool fromBase64 = true, bool chooseBetterImage = false); } @@ -719,6 +720,14 @@ public class CoverDbService : ICoverDbService } } + public async Task SetUserCoverByUrl(int userId, string url, bool fromBase64 = true, bool chooseBetterImage = false) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences); + if (user == null) return; + + await SetUserCoverByUrl(user, url, fromBase64, chooseBetterImage); + } + public async Task SetUserCoverByUrl(AppUser user, string url, bool fromBase64 = true, bool chooseBetterImage = false) { if (!string.IsNullOrEmpty(url)) diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 711d8ad40..9402fc498 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -85,6 +85,12 @@ public static partial class Parser public static readonly Regex CssImageUrlRegex = new(@"(url\((?!data:).(?!data:))" + "(?(?!data:)[^\"']*)" + @"(.\))", MatchOptions, RegexTimeout); + /// + /// An Appropriate guess at an ASIN being valid + /// + public static readonly Regex AsinRegex = new(@"^(B0|BT)[0-9A-Z]{8}$", + MatchOptions, RegexTimeout); + private static readonly Regex ImageRegex = new(ImageFileExtensions, MatchOptions, RegexTimeout); @@ -1300,6 +1306,17 @@ public static partial class Parser return filename; } + /// + /// Checks if code is an Amazon ASIN + /// + /// + /// + public static bool IsLikelyValidAsin(string? asin) + { + if (string.IsNullOrEmpty(asin)) return false; + return AsinRegex.Match(asin).Success; + } + [GeneratedRegex(SupportedExtensions)] private static partial Regex SupportedExtensionsRegex(); diff --git a/API/Services/Tasks/StatsService.cs b/API/Services/Tasks/StatsService.cs index e9a3315d5..f60e7c952 100644 --- a/API/Services/Tasks/StatsService.cs +++ b/API/Services/Tasks/StatsService.cs @@ -48,7 +48,7 @@ public class StatsService : IStatsService private readonly UserManager _userManager; private readonly IEmailService _emailService; private readonly ICacheService _cacheService; - private readonly string _apiUrl = ""; + private readonly string _apiUrl; private const string ApiKey = "MsnvA2DfQqxSK5jh"; // It's not important this is public, just a way to keep bots from hitting the API willy-nilly public StatsService(ILogger logger, IUnitOfWork unitOfWork, DataContext context, diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 08deccc7f..82932169e 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -1,4 +1,5 @@ using System; +using API.DTOs.Account; using API.DTOs.Reader; using API.DTOs.Update; using API.Entities.Person; @@ -166,6 +167,14 @@ public static class MessageFactory /// A Session is closing /// public const string SessionClose = "SessionClose"; + /// + /// Auth key has been rotated, created + /// + public const string AuthKeyUpdate = nameof(AuthKeyUpdate); + /// + /// An Auth key has been deleted + /// + public const string AuthKeyDeleted = nameof(AuthKeyDeleted); @@ -738,4 +747,28 @@ public static class MessageFactory } }; } + + public static SignalRMessage AuthKeyUpdatedEvent(AuthKeyDto authKey) + { + return new SignalRMessage + { + Name = AuthKeyUpdate, + Body = new + { + AuthKey = authKey + } + }; + } + + public static SignalRMessage AuthKeyDeletedEvent(int id) + { + return new SignalRMessage + { + Name = AuthKeyDeleted, + Body = new + { + Id = id + } + }; + } } diff --git a/API/Startup.cs b/API/Startup.cs index ca139a53a..61687b710 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -153,19 +153,24 @@ public class Startup var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"; var filePath = Path.Combine(AppContext.BaseDirectory, xmlFile); + c.IncludeXmlComments(filePath, true); - c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { + + c.AddSecurityDefinition("AuthKey", new OpenApiSecurityScheme + { + Description = "Auth Key authentication. Enter your Auth key from your user settings", + Name = Headers.ApiKey, In = ParameterLocation.Header, - Description = "Please insert JWT with Bearer into field", - Name = "Authorization", - Type = SecuritySchemeType.ApiKey + Type = SecuritySchemeType.ApiKey, + Scheme = "ApiKeyScheme" }); c.AddSecurityRequirement((document) => new OpenApiSecurityRequirement() { - [new OpenApiSecuritySchemeReference("bearer", document)] = [] + [new OpenApiSecuritySchemeReference("apiKey", document)] = [] }); + c.AddServer(new OpenApiServer { Url = "{protocol}://{hostpath}", diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index 76d2a39e1..ea7d98fb1 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -19,9 +19,23 @@ public static class Configuration public const string DefaultOidcClientId = "kavita"; private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename()); - public static readonly string KavitaPlusApiUrl = "https://plus.kavitareader.com"; + public static readonly string KavitaPlusApiUrl = GetKavitaPlusApiUrl(); public const string StatsApiUrl = "https://stats.kavitareader.com"; + + private static string GetKavitaPlusApiUrl() + { + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); + var isDevelopment = environment == Environments.Development; + + if (isDevelopment && Environment.UserName.Equals("Joe", StringComparison.OrdinalIgnoreCase)) + { + return "http://localhost:5020"; + } + + return "https://plus.kavitareader.com"; + } + public static int Port { get => GetPort(GetAppSettingFilename()); diff --git a/UI/Web/src/app/_models/readers/reread-prompt.ts b/UI/Web/src/app/_models/readers/reread-prompt.ts new file mode 100644 index 000000000..aaa6f042d --- /dev/null +++ b/UI/Web/src/app/_models/readers/reread-prompt.ts @@ -0,0 +1,18 @@ +import {MangaFormat} from "../manga-format"; + + +export type RereadPrompt = { + shouldPrompt: boolean; + timePrompt: boolean; + daysSinceLastRead: number; + chapterOnContinue: RereadChapter; + chapterOnReread: RereadChapter; +} + +export type RereadChapter = { + libraryId: number; + seriesId: number; + chapterId: number; + label: string; + format: MangaFormat, +} diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index ca70bca26..e6f1e3d0d 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -87,6 +87,33 @@ export class AccountService { switchMap(() => this.refreshAccount())) .subscribe(() => {}); + this.messageHub.messages$.pipe( + filter(evt => evt.event === EVENTS.AuthKeyUpdate), + map(evt => evt.payload as {authKey: AuthKey}), + tap(({authKey}) => { + const authKeys = this.currentUser!.authKeys.map(k => k.id === authKey.id ? authKey : k); + + this.setCurrentUser({ + ...this.currentUser!, + authKeys: authKeys, + }, false); + }), + ).subscribe(); + + this.messageHub.messages$.pipe( + filter(evt => evt.event === EVENTS.AuthKeyDeleted), + map(evt => evt.payload as {id: number}), + tap(({id}) => { + const authKeys = this.currentUser!.authKeys.filter(k => k.id !== id); + + this.setCurrentUser({ + ...this.currentUser!, + authKeys: authKeys, + }, false); + }), + ).subscribe(); + + window.addEventListener("offline", (e) => { this.isOnline = false; }); @@ -444,14 +471,7 @@ export class AccountService { } getAuthKeys() { - return this.httpClient.get(this.baseUrl + `account/auth-keys`).pipe( - tap(authKeys => { - this.setCurrentUser({ - ...this.currentUser!, - authKeys: authKeys, - }, false); - }), - ); + return this.httpClient.get(this.baseUrl + `account/auth-keys`); } createAuthKey(data: {keyLength: number, name: string, expiresUtc: string | null}) { diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index 0903e089f..7ea0e88b2 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -130,6 +130,14 @@ export enum EVENTS { * Reading Session close */ SessionClose = 'SessionClose', + /** + * Auth key has been rotated, created + */ + AuthKeyUpdate = 'AuthKeyUpdate', + /** + * An Auth key has been deleted + */ + AuthKeyDeleted = 'AuthKeyDeleted', } export interface Message { @@ -385,7 +393,21 @@ export class MessageHubService { event: EVENTS.PersonMerged, payload: resp.body }); - }) + }); + + this.hubConnection.on(EVENTS.AuthKeyUpdate, resp => { + this.messagesSource.next({ + event: EVENTS.AuthKeyUpdate, + payload: resp.body + }); + }); + + this.hubConnection.on(EVENTS.AuthKeyDeleted, resp => { + this.messagesSource.next({ + event: EVENTS.AuthKeyDeleted, + payload: resp.body + }); + }); } stopHubConnection() { diff --git a/UI/Web/src/app/_services/person.service.ts b/UI/Web/src/app/_services/person.service.ts index 1ca8495da..3fe4ec4d6 100644 --- a/UI/Web/src/app/_services/person.service.ts +++ b/UI/Web/src/app/_services/person.service.ts @@ -1,4 +1,4 @@ -import { Injectable, inject } from '@angular/core'; +import {inject, Injectable} from '@angular/core'; import {HttpClient, HttpParams} from "@angular/common/http"; import {environment} from "../../environments/environment"; import {Person, PersonRole} from "../_models/metadata/person"; @@ -80,12 +80,6 @@ export class PersonService { ); } - isValidAsin(asin: string) { - return this.httpClient.get(this.baseUrl + `person/valid-asin?asin=${asin}`, TextResonse).pipe( - map(valid => valid + '' === 'true') - ); - } - mergePerson(destId: number, srcId: number) { return this.httpClient.post(this.baseUrl + 'person/merge', {destId, srcId}); } diff --git a/UI/Web/src/app/_services/reader.service.ts b/UI/Web/src/app/_services/reader.service.ts index 39af1325d..ab546d42f 100644 --- a/UI/Web/src/app/_services/reader.service.ts +++ b/UI/Web/src/app/_services/reader.service.ts @@ -24,11 +24,12 @@ import {translate} from "@jsverse/transloco"; import {ToastrService} from "ngx-toastr"; import {FilterField} from "../_models/metadata/v2/filter-field"; import {ModalService} from "./modal.service"; -import {filter, map, Observable, of, switchMap, tap} from "rxjs"; +import {map, of, switchMap, tap} from "rxjs"; import {ListSelectModalComponent} from "../shared/_components/list-select-modal/list-select-modal.component"; import {take, takeUntil} from "rxjs/operators"; import {SeriesService} from "./series.service"; import {Series} from "../_models/series"; +import {RereadChapter, RereadPrompt} from "../_models/readers/reread-prompt"; enum RereadPromptResult { Cancel = 0, @@ -605,116 +606,73 @@ export class ReaderService { return parentPath ? `${parentPath}/${currentPath}` : currentPath; } - private handleRereadPrompt( - type: 'series' | 'volume' | 'chapter', - target: Chapter, - incognitoMode: boolean, - onReread: () => Observable - ): Observable<{ shouldContinue: boolean; incognitoMode: boolean }> { - return this.promptForReread(type, target, incognitoMode).pipe( - switchMap(result => { - switch (result) { - case RereadPromptResult.Cancel: - return of({ shouldContinue: false, incognitoMode }); - case RereadPromptResult.Continue: - return of({ shouldContinue: true, incognitoMode }); - case RereadPromptResult.ReadIncognito: - return of({ shouldContinue: true, incognitoMode: true }); - case RereadPromptResult.Reread: - return onReread().pipe(map(() => ({ shouldContinue: true, incognitoMode }))); - } - }) - ); + private shouldPromptForSeriesReread(seriesId: number) { + return this.httpClient.get(this.baseUrl + `reader/prompt-reread/series?seriesId=${seriesId}`); } - readSeries(series: Series, incognitoMode: boolean = false, callback?: (chapter: Chapter) => void) { - const fullyRead = series.pagesRead >= series.pages; - const shouldPromptForReread = fullyRead && !incognitoMode; + private shouldPromptForVolumeReread(libraryId: number, seriesId: number, volumeId: number) { + return this.httpClient.get(this.baseUrl + `reader/prompt-reread/volume?libraryId=${libraryId}&seriesId=${seriesId}&volumeId=${volumeId}`); + } - if (!shouldPromptForReread) { - this.getCurrentChapter(series.id).subscribe(chapter => { - this.readChapter(series.libraryId, series.id, chapter, incognitoMode); - }); - return; - } + private shouldPromptForChapterReread(libraryId: number, seriesId: number, chapterId: number) { + return this.httpClient.get(this.baseUrl + `reader/prompt-reread/chapter?libraryId=${libraryId}&seriesId=${seriesId}&chapterId=${chapterId}`); + } - this.getCurrentChapter(series.id).pipe( - switchMap(chapter => - this.handleRereadPrompt('series', chapter, incognitoMode, - () => this.seriesService.markUnread(series.id)).pipe( - map(result => ({ chapter, result })) - )), - filter(({ result }) => result.shouldContinue), - tap(({ chapter, result }) => { - if (callback) { - callback(chapter); - } - - this.readChapter(series.libraryId, series.id, chapter, result.incognitoMode, false); - }) + readSeries(series: Series, incognitoMode: boolean = false) { + this.shouldPromptForSeriesReread(series.id).pipe( + switchMap(prompt => this.handlePrompt(prompt, incognitoMode)), + tap(res => this.handlePromptResult(res)), ).subscribe(); } readVolume(libraryId: number, seriesId: number, volume: Volume, incognitoMode: boolean = false) { - if (volume.chapters.length === 0) return; - - const sortedChapters = [...volume.chapters].sort(this.utilityService.sortChapters); - - // No reading progress or incognito - start from first chapter - if (volume.pagesRead === 0 || incognitoMode) { - this.readChapter(libraryId, seriesId, sortedChapters[0], incognitoMode); - return; - } - - // Not fully read - continue from current position - if (volume.pagesRead < volume.pages) { - const unreadChapters = volume.chapters.filter(item => item.pagesRead < item.pages); - const chapterToRead = unreadChapters.length > 0 ? unreadChapters[0] : sortedChapters[0]; - this.readChapter(libraryId, seriesId, chapterToRead, incognitoMode); - return; - } - - // Fully read - prompt for reread - const lastChapter = volume.chapters[volume.chapters.length - 1]; - this.handleRereadPrompt('volume', lastChapter, incognitoMode, - () => this.markVolumeUnread(seriesId, volume.id)).pipe( - filter(result => result.shouldContinue), - tap(result => { - this.readChapter(libraryId, seriesId, sortedChapters[0], result.incognitoMode, false); - }) - ).subscribe(); + this.shouldPromptForVolumeReread(libraryId, seriesId, volume.id).pipe( + switchMap(prompt => this.handlePrompt(prompt, incognitoMode)), + tap(res => this.handlePromptResult(res)), + ).subscribe() } - readChapter(libraryId: number, seriesId: number, chapter: Chapter, incognitoMode: boolean = false, promptForReread: boolean = true) { + readChapter(libraryId: number, seriesId: number, chapter: Chapter, incognitoMode: boolean = false) { if (chapter.pages === 0) { this.toastr.error(translate('series-detail.no-pages')); return; } - const navigateToReader = (useIncognitoMode: boolean) => { - this.router.navigate( - this.getNavigationArray(libraryId, seriesId, chapter.id, chapter.files[0].format), - { queryParams: { incognitoMode: useIncognitoMode } } - ); - }; - - if (!promptForReread) { - navigateToReader(incognitoMode); - return; - } - - this.handleRereadPrompt('chapter', chapter, incognitoMode, - () => this.saveProgress(libraryId, seriesId, chapter.volumeId, chapter.id, 0) - ).pipe( - filter(result => result.shouldContinue), - tap(result => navigateToReader(result.incognitoMode)) - ).subscribe(); + this.shouldPromptForChapterReread(libraryId, seriesId, chapter.id).pipe( + switchMap(prompt => this.handlePrompt(prompt, incognitoMode)), + tap(res => this.handlePromptResult(res)), + ).subscribe() } - promptForReread(entityType: 'series' | 'volume' | 'chapter', chapter: Chapter, incognitoMode: boolean) { - if (!this.shouldPromptForReread(chapter, incognitoMode)) return of(RereadPromptResult.Continue); + private handlePromptResult({prompt, result}: {prompt: RereadPrompt, result: RereadPromptResult}) { + let chapter: RereadChapter; + let useIncognitoMode = false; - const fullyRead = chapter.pagesRead >= chapter.pages; + switch (result) { + case RereadPromptResult.Cancel: + return; + case RereadPromptResult.Reread: + chapter = prompt.chapterOnReread; + break; + case RereadPromptResult.ReadIncognito: + useIncognitoMode = true; + chapter = prompt.chapterOnContinue; + break; + case RereadPromptResult.Continue: + chapter = prompt.chapterOnContinue; + break; + } + + this.router.navigate( + this.getNavigationArray(chapter.libraryId, chapter.seriesId, chapter.chapterId, chapter.format), + { queryParams: { incognitoMode: useIncognitoMode } } + ).catch(err => console.error(err)); + } + + private handlePrompt(prompt: RereadPrompt, incognitoMode: boolean) { + if (!prompt.shouldPrompt) return of({prompt: prompt, result: RereadPromptResult.Continue}); + + if (incognitoMode) return of({prompt: prompt, result: RereadPromptResult.ReadIncognito}); const [modal, component] = this.modalService.open(ListSelectModalComponent, { centered: true, @@ -723,12 +681,11 @@ export class ReaderService { component.showFooter.set(false); component.title.set(translate('reread-modal.title')); - const daysSinceRead = Math.round((new Date().getTime() - new Date(chapter.lastReadingProgress).getTime()) / MS_IN_DAY); - - if (chapter.pagesRead >= chapter.pages) { - component.description.set(translate('reread-modal.description-full-read', { entityType: translate('entity-type.' + entityType) })); + if (prompt.timePrompt) { + component.description.set(translate('reread-modal.description-time-passed', + { days: prompt.daysSinceLastRead, name: prompt.chapterOnReread.label })); } else { - component.description.set(translate('reread-modal.description-time-passed', { days: daysSinceRead, entityType: translate('entity-type.' + entityType) })); + component.description.set(translate('reread-modal.description-full-read', { name: prompt.chapterOnReread.label })); } const options = [ @@ -736,7 +693,7 @@ export class ReaderService { {label: translate('reread-modal.continue'), value: RereadPromptResult.Continue}, ]; - if (fullyRead) { + if (!prompt.timePrompt) { options.push({label: translate('reread-modal.read-incognito'), value: RereadPromptResult.ReadIncognito}); } @@ -747,22 +704,8 @@ export class ReaderService { return modal.closed.pipe( takeUntil(modal.dismissed), take(1), - map(res => res as RereadPromptResult), + map(res => ({prompt: prompt, result: res as RereadPromptResult})), ); } - private shouldPromptForReread(chapter: Chapter, incognitoMode: boolean) { - if (incognitoMode || chapter.pagesRead === 0) return false; - if (chapter.pagesRead >= chapter.pages) return true; - - const userPreferences = this.accountService.currentUserSignal()!.preferences; - - if (!userPreferences.promptForRereadsAfter) { - return false; - } - - const daysSinceRead = (new Date().getTime() - new Date(chapter.lastReadingProgress).getTime()) / MS_IN_DAY; - return daysSinceRead > userPreferences.promptForRereadsAfter; - } - } diff --git a/UI/Web/src/app/_services/statistics.service.ts b/UI/Web/src/app/_services/statistics.service.ts index 07b3f8bde..e50ea5ee6 100644 --- a/UI/Web/src/app/_services/statistics.service.ts +++ b/UI/Web/src/app/_services/statistics.service.ts @@ -55,7 +55,7 @@ export class StatisticsService { mangaFormatPipe = new MangaFormatPipe(); getUserStatistics(userId: number, libraryIds: Array = []) { - const url = `${this.baseUrl}stats/user/${userId}/read`; + const url = `${this.baseUrl}stats/user-read?userId=${userId}`; let params = new HttpParams(); if (libraryIds.length > 0) { @@ -65,6 +65,10 @@ export class StatisticsService { return this.httpClient.get(url, { params }); } + getUserStatisticsResource(userId: () => number) { + return httpResource(() => this.baseUrl + `stats/user-read?userId=${userId()}`).asReadonly(); + } + getServerStatistics() { return this.httpClient.get(this.baseUrl + 'stats/server/stats'); } diff --git a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.ts b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.ts index 9cff334f9..8768ff1af 100644 --- a/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.ts +++ b/UI/Web/src/app/_single-module/actionable-modal/actionable-modal.component.ts @@ -12,7 +12,7 @@ import { import {translate, TranslocoDirective} from "@jsverse/transloco"; import {Breakpoint, UtilityService} from "../../shared/_services/utility.service"; import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; -import {ActionableEntity, ActionItem} from "../../_services/action-factory.service"; +import {Action, ActionableEntity, ActionItem} from "../../_services/action-factory.service"; import {AccountService} from "../../_services/account.service"; import {tap} from "rxjs"; import {User} from "../../_models/user/user"; @@ -49,6 +49,21 @@ export class ActionableModalComponent implements OnInit { ngOnInit() { this.currentItems = this.translateOptions(this.actions); + // On Mobile, surface download + const otherActionIndex = this.currentItems.findIndex(i => i.action === Action.Submenu && i.title === 'actionable.other') + if (otherActionIndex) { + const downloadActionIndex = this.currentItems[otherActionIndex].children.findIndex(a => a.action === Action.Download); + if (downloadActionIndex) { + const downloadAction = this.currentItems[otherActionIndex].children.splice(downloadActionIndex, 1)[0]; + this.currentItems.push(downloadAction); + + // Check if Other has any other children, else remove + if (this.currentItems[otherActionIndex].children.length === 0) { + this.currentItems.splice(otherActionIndex, 1); + } + } + } + this.accountService.currentUser$.pipe(tap(user => { this.user = user; this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.ts b/UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.ts index 48040d6b5..9e735706d 100644 --- a/UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.ts +++ b/UI/Web/src/app/_single-module/age-rating-image/age-rating-image.component.ts @@ -65,7 +65,7 @@ export class AgeRatingImageComponent { } openRating() { - this.filterUtilityService.applyFilter(['all-series'], FilterField.AgeRating, FilterComparison.Equal, `${this.rating}`).subscribe(); + this.filterUtilityService.applyFilter(['all-series'], FilterField.AgeRating, FilterComparison.Equal, `${this.rating()}`).subscribe(); } diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html index f346768c5..c34a23295 100644 --- a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.html @@ -620,13 +620,6 @@ - -
  • - {{t(TabID.Progress)}} - - - -
  • diff --git a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts index d850facc1..b2a15f00a 100644 --- a/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts +++ b/UI/Web/src/app/_single-module/edit-chapter-modal/edit-chapter-modal.component.ts @@ -22,11 +22,10 @@ import {DownloadService} from "../../shared/_services/download.service"; import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component"; import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component"; import {concat, forkJoin, Observable, of, tap} from "rxjs"; -import {map, switchMap} from "rxjs/operators"; +import {map} from "rxjs/operators"; import {EntityTitleComponent} from "../../cards/entity-title/entity-title.component"; import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component"; import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.component"; -import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {CompactNumberPipe} from "../../_pipes/compact-number.pipe"; import {MangaFormat} from "../../_models/manga-format"; @@ -46,7 +45,6 @@ enum TabID { Info = 'info-tab', People = 'people-tab', Tasks = 'tasks-tab', - Progress = 'progress-tab', Tags = 'tags-tab' } @@ -62,33 +60,32 @@ const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList]; @Component({ selector: 'app-edit-chapter-modal', - imports: [ - FormsModule, - NgbNav, - NgbNavContent, - NgbNavLink, - TranslocoDirective, - AsyncPipe, - NgbNavOutlet, - ReactiveFormsModule, - NgbNavItem, - SettingItemComponent, - NgTemplateOutlet, - NgClass, - TypeaheadComponent, - EntityTitleComponent, - TitleCasePipe, - SettingButtonComponent, - CoverImageChooserComponent, - EditChapterProgressComponent, - CompactNumberPipe, - DefaultDatePipe, - UtcToLocalTimePipe, - BytesPipe, - ImageComponent, - SafeHtmlPipe, - ReadTimePipe, - ], + imports: [ + FormsModule, + NgbNav, + NgbNavContent, + NgbNavLink, + TranslocoDirective, + AsyncPipe, + NgbNavOutlet, + ReactiveFormsModule, + NgbNavItem, + SettingItemComponent, + NgTemplateOutlet, + NgClass, + TypeaheadComponent, + EntityTitleComponent, + TitleCasePipe, + SettingButtonComponent, + CoverImageChooserComponent, + CompactNumberPipe, + DefaultDatePipe, + UtcToLocalTimePipe, + BytesPipe, + ImageComponent, + SafeHtmlPipe, + ReadTimePipe, + ], templateUrl: './edit-chapter-modal.component.html', styleUrl: './edit-chapter-modal.component.scss', changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html index a638fa614..a5acfa774 100644 --- a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html +++ b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.html @@ -105,18 +105,6 @@
  • } - -
  • - {{t(TabID.Progress)}} - - @for(chapter of volume.chapters; track chapter.id) { -
    - -
    - } -
    -
  • -
  • {{t(TabID.Tasks)}} diff --git a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts index 4f2099121..05d0062c8 100644 --- a/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts +++ b/UI/Web/src/app/_single-module/edit-volume-modal/edit-volume-modal.component.ts @@ -7,7 +7,6 @@ import {SettingItemComponent} from "../../settings/_components/setting-item/sett import {EntityTitleComponent} from "../../cards/entity-title/entity-title.component"; import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component"; import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.component"; -import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component"; import {CompactNumberPipe} from "../../_pipes/compact-number.pipe"; import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; @@ -63,7 +62,6 @@ const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList]; EntityTitleComponent, SettingButtonComponent, CoverImageChooserComponent, - EditChapterProgressComponent, CompactNumberPipe, DefaultDatePipe, UtcToLocalTimePipe, diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html index 5dcdb889e..4e6c819af 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.html @@ -28,113 +28,207 @@ - + + - - -
    - - -
    -
    - - - -
    + + +
    + + +
    +
    + + + +
    - - - {{t('created-header')}} - - - {{value | utcToLocalTime | defaultValue }} - - + + + {{t('created-header')}} + + + {{value | utcToLocalTime | defaultValue }} + + - - - {{t('type-header')}} - - - {{value | scrobbleEventType}} - - + + + {{t('type-header')}} + + + {{value | scrobbleEventType}} + + - - - {{t('series-header')}} - - - {{item.seriesName}} - - + + + {{t('series-header')}} + + + {{item.seriesName}} + + - - - {{t('data-header')}} - - - @switch (item.scrobbleEventType) { - @case (ScrobbleEventType.ChapterRead) { - @if(item.volumeNumber === LooseLeafOrDefaultNumber) { - @if (item.chapterNumber === LooseLeafOrDefaultNumber) { - {{t('special')}} - } @else { - {{t('chapter-num', {num: item.chapterNumber})}} + + + {{t('data-header')}} + + + @switch (item.scrobbleEventType) { + @case (ScrobbleEventType.ChapterRead) { + @if(item.volumeNumber === LooseLeafOrDefaultNumber) { + @if (item.chapterNumber === LooseLeafOrDefaultNumber) { + {{t('special')}} + } @else { + {{t('chapter-num', {num: item.chapterNumber})}} + } + } + @else if (item.chapterNumber === LooseLeafOrDefaultNumber) { + {{t('volume-num', {num: item.volumeNumber})}} + } + @else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) { + Special + } + @else { + {{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}} } } - @else if (item.chapterNumber === LooseLeafOrDefaultNumber) { - {{t('volume-num', {num: item.volumeNumber})}} + @case (ScrobbleEventType.ScoreUpdated) { + {{t('rating', {r: item.rating})}} } - @else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) { - Special - } - @else { - {{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}} + @default { + {{t('not-applicable')}} } } - @case (ScrobbleEventType.ScoreUpdated) { - {{t('rating', {r: item.rating})}} - } - @default { - {{t('not-applicable')}} - } - } - - + + - - - {{t('is-processed-header')}} - - - @if(item.isProcessed) { - - } @else if (item.isErrored) { - - } @else { - - } - + + + {{t('is-processed-header')}} + + + @if(item.isProcessed) { + + } @else if (item.isErrored) { + + } @else { + + } + {{item.isProcessed ? t('processed') : t('not-processed')}} - - + + -
    +
    + +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + + + +
    + +
    +
    {{t('created-header')}}
    +
    {{event.createdUtc | utcToLocalTime | defaultValue}}
    +
    + + +
    +
    {{t('type-header')}}
    +
    {{event.scrobbleEventType | scrobbleEventType}}
    +
    + + +
    +
    {{t('data-header')}}
    +
    + @switch (event.scrobbleEventType) { + @case (ScrobbleEventType.ChapterRead) { + @if(event.volumeNumber === LooseLeafOrDefaultNumber) { + @if (event.chapterNumber === LooseLeafOrDefaultNumber) { + {{t('special')}} + } @else { + {{t('chapter-num', {num: event.chapterNumber})}} + } + } + @else if (event.chapterNumber === LooseLeafOrDefaultNumber) { + {{t('volume-num', {num: event.volumeNumber})}} + } + @else if (event.chapterNumber === LooseLeafOrDefaultNumber && event.volumeNumber === SpecialVolumeNumber) { + {{t('special')}} + } + @else { + {{t('volume-and-chapter-num', {v: event.volumeNumber, n: event.chapterNumber})}} + } + } + @case (ScrobbleEventType.ScoreUpdated) { + {{t('rating', {r: event.rating})}} + } + @default { + {{t('not-applicable')}} + } + } +
    +
    + + +
    +
    {{t('is-processed-header')}}
    +
    + @if(event.isProcessed) { + + {{t('processed')}} + } @else if (event.isErrored) { + + {{t('error')}} + } @else { + + {{t('not-processed')}} + } +
    +
    +
    +
    +
    +
    + diff --git a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts index 1d32490cb..857bbdd80 100644 --- a/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts +++ b/UI/Web/src/app/_single-module/user-scrobble-history/user-scrobble-history.component.ts @@ -28,6 +28,7 @@ import {APP_BASE_HREF, AsyncPipe} from "@angular/common"; import {AccountService} from "../../_services/account.service"; import {ToastrService} from "ngx-toastr"; import {SelectionModel} from "../../typeahead/_models/selection-model"; +import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component"; export interface DataTablePage { pageNumber: number, @@ -39,7 +40,7 @@ export interface DataTablePage { @Component({ selector: 'app-user-scrobble-history', imports: [ScrobbleEventTypePipe, ReactiveFormsModule, TranslocoModule, - DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, AsyncPipe, FormsModule], + DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, AsyncPipe, FormsModule, ResponsiveTableComponent], templateUrl: './user-scrobble-history.component.html', styleUrls: ['./user-scrobble-history.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -81,6 +82,8 @@ export class UserScrobbleHistoryComponent implements OnInit { isShiftDown: boolean = false; lastSelectedIndex: number | null = null; + trackByEvents = (idx: number, data: ScrobbleEvent) => `${data.isProcessed}_${data.isErrored}_${data.id}`; + @HostListener('document:keydown.shift', ['$event']) handleKeypress(_: KeyboardEvent) { this.isShiftDown = true; diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.html b/UI/Web/src/app/admin/edit-user/edit-user.component.html index fe5a5f948..797d9ae9f 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.html +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.html @@ -105,6 +105,7 @@ diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.ts b/UI/Web/src/app/admin/edit-user/edit-user.component.ts index b3c774236..a06cd1e98 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.ts +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.ts @@ -18,7 +18,7 @@ import {AccountService, allRoles, Role} from 'src/app/_services/account.service' import {SentenceCasePipe} from '../../_pipes/sentence-case.pipe'; import {RestrictionSelectorComponent} from '../../user-settings/restriction-selector/restriction-selector.component'; import {AsyncPipe} from '@angular/common'; -import {TranslocoDirective} from "@jsverse/transloco"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; import {debounceTime, distinctUntilChanged, Observable, startWith, tap} from "rxjs"; import {map} from "rxjs/operators"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @@ -73,6 +73,7 @@ export class EditUserComponent implements OnInit { userForm: FormGroup = new FormGroup({}); isEmailInvalid$!: Observable; + readOnlyWarning$!: Observable; allowedCharacters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+/'; @@ -109,6 +110,14 @@ export class EditUserComponent implements OnInit { map(value => !EmailRegex.test(value)), takeUntilDestroyed(this.destroyRef) ); + this.readOnlyWarning$ = this.userForm.get('roles')!.valueChanges.pipe( + startWith(this.member().roles), + takeUntilDestroyed(this.destroyRef), + distinctUntilChanged(), + debounceTime(10), + map((roles: string[]) => roles.includes(Role.ReadOnly)), + map(readOnlySelected => readOnlySelected ? translate('edit-user.warning-read-only') : undefined), + ); this.selectedRestriction = this.member().ageRestriction; this.cdRef.markForCheck(); diff --git a/UI/Web/src/app/admin/email-history/email-history.component.html b/UI/Web/src/app/admin/email-history/email-history.component.html index 38decf5b3..d50bd0577 100644 --- a/UI/Web/src/app/admin/email-history/email-history.component.html +++ b/UI/Web/src/app/admin/email-history/email-history.component.html @@ -1,7 +1,8 @@

    {{t('description')}}

    - + + +
    +
    + +
    +
    {{item.emailTemplate}}
    +
    + @if (item.sent) { + + {{t('sent-tooltip')}} + } @else { + + {{t('not-sent-tooltip')}} + } +
    +
    + + +
    +
    +
    {{t('date-header')}}
    +
    {{item.sendDate | utcToLocalTime}}
    +
    + +
    +
    {{t('user-header')}}
    +
    {{item.toUserName}}
    +
    +
    +
    +
    +
    +
    diff --git a/UI/Web/src/app/admin/email-history/email-history.component.ts b/UI/Web/src/app/admin/email-history/email-history.component.ts index 7acced3ba..946c24b18 100644 --- a/UI/Web/src/app/admin/email-history/email-history.component.ts +++ b/UI/Web/src/app/admin/email-history/email-history.component.ts @@ -5,6 +5,7 @@ import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {EmailHistory} from "../../_models/email-history"; import {EmailService} from "../../_services/email.service"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; +import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component"; @Component({ selector: 'app-email-history', @@ -12,7 +13,8 @@ import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; TranslocoDirective, VirtualScrollerModule, UtcToLocalTimePipe, - NgxDatatableModule + NgxDatatableModule, + ResponsiveTableComponent ], templateUrl: './email-history.component.html', styleUrl: './email-history.component.scss', @@ -25,6 +27,8 @@ export class EmailHistoryComponent implements OnInit { isLoading = true; data: Array = []; + trackBy = (index: number, item: EmailHistory) => `${item.sent}_${item.emailTemplate}_${index}`; + ngOnInit() { this.emailService.getEmailHistory().subscribe(data => { this.data = data; diff --git a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html index 0a53492ec..447aeec31 100644 --- a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html +++ b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.html @@ -23,6 +23,7 @@ + + + +
    +
    + +
    + +
    +
    + + {{item.series.name}} + +
    +
    + {{item.series.libraryId | libraryName | async}} +
    +
    + +
    + + +
    +
    +
    {{t('status-header')}}
    +
    + @if (item.series.isBlacklisted) { + {{t('blacklist-status-label')}} + } @else if (item.series.dontMatch) { + {{t('dont-match-status-label')}} + } @else { + @if (item.isMatched) { + {{t('matched-status-label')}} + } @else { + {{t('unmatched-status-label')}} + } + } +
    +
    + + @if (filterGroup.get('matchState')?.value === MatchStateOption.Matched) { +
    +
    {{t('valid-until-header')}}
    +
    + @if (item.series.isBlacklisted || item.series.dontMatch || !item.isMatched) { + {{null | defaultValue}} + } @else { + {{item.validUntilUtc | utcToLocalTime}} + } +
    +
    + } +
    +
    +
    +
    +
    diff --git a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts index 2feb06be6..bce8ae3a0 100644 --- a/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts +++ b/UI/Web/src/app/admin/manage-matched-metadata/manage-matched-metadata.component.ts @@ -25,6 +25,7 @@ import {LibraryTypePipe} from "../../_pipes/library-type.pipe"; import {allKavitaPlusMetadataApplicableTypes} from "../../_models/library/library"; import {ExternalMatchRateLimitErrorEvent} from "../../_models/events/external-match-rate-limit-error-event"; import {ToastrService} from "ngx-toastr"; +import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component"; @Component({ selector: 'app-manage-matched-metadata', @@ -40,6 +41,7 @@ import {ToastrService} from "ngx-toastr"; LibraryNamePipe, AsyncPipe, LibraryTypePipe, + ResponsiveTableComponent, ], templateUrl: './manage-matched-metadata.component.html', styleUrl: './manage-matched-metadata.component.scss', @@ -68,6 +70,7 @@ export class ManageMatchedMetadataComponent implements OnInit { 'matchState': new FormControl(MatchStateOption.Error, []), 'libraryType': new FormControl(-1, []), // Denotes all }); + trackBy = (idx: number, item: ManageMatchSeries) => `${item.isMatched}_${item.series.name}_${idx}`; ngOnInit() { this.licenseService.hasValidLicense$.subscribe(license => { diff --git a/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.html b/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.html index 2639823fe..c3ac56d88 100644 --- a/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.html +++ b/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.html @@ -12,41 +12,65 @@ - - - - {{t('file-header')}} - - - {{item.filePath}} - - + @let filteredRows = data | filter: filterList; + + + + + + {{t('file-header')}} + + + {{item.filePath}} + + - - - {{t('comment-header')}} - - - {{item.comment}} - - + + + {{t('comment-header')}} + + + {{item.comment}} + + - - - {{t('created-header')}} - - - {{item.createdUtc | utcToLocalTime | defaultDate}} - - - + + + {{t('created-header')}} + + + {{item.createdUtc | utcToLocalTime | defaultDate}} + + + + + +
    +
    +
    {{item.filePath}}
    + +
    +
    +
    {{t('comment-header')}}
    +
    {{item.comment}}
    +
    + +
    +
    {{t('created-header')}}
    +
    {{item.createdUtc | utcToLocalTime | defaultDate}}
    +
    +
    +
    +
    +
    + diff --git a/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.ts b/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.ts index b2d983fa4..ee5bf5e63 100644 --- a/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.ts +++ b/UI/Web/src/app/admin/manage-media-issues/manage-media-issues.component.ts @@ -23,12 +23,13 @@ import {WikiLink} from "../../_models/wiki"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; +import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component"; @Component({ selector: 'app-manage-media-issues', templateUrl: './manage-media-issues.component.html', styleUrls: ['./manage-media-issues.component.scss'], - imports: [ReactiveFormsModule, FilterPipe, TranslocoDirective, UtcToLocalTimePipe, DefaultDatePipe, NgxDatatableModule], + imports: [ReactiveFormsModule, FilterPipe, TranslocoDirective, UtcToLocalTimePipe, DefaultDatePipe, NgxDatatableModule, ResponsiveTableComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ManageMediaIssuesComponent implements OnInit { @@ -53,6 +54,7 @@ export class ManageMediaIssuesComponent implements OnInit { formGroup = new FormGroup({ filter: new FormControl('', []) }); + trackBy = (idx: number, item: KavitaMediaError) => `${item.filePath}` ngOnInit(): void { diff --git a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html b/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html index 66f297729..555b65833 100644 --- a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html +++ b/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.html @@ -14,54 +14,86 @@ + @let filteredData = data | filter: filterList; + + - - - - - {{t('series-header')}} - - - {{item.details}} - - + + + {{t('series-header')}} + + + {{item.details}} + + - - - {{t('created-header')}} - - - {{item.created | utcToLocalTime | defaultValue }} - - + + + {{t('created-header')}} + + + {{item.created | utcToLocalTime | defaultValue }} + + - - - {{t('comment-header')}} - - - {{item.comment}} - - + + + {{t('comment-header')}} + + + {{item.comment}} + + - - - {{t('edit-header')}} - - - - - - + + + {{t('edit-header')}} + + + + + + + + +
    +
    +
    +
    + + {{item.details}} + +
    + +
    + +
    +
    +
    {{t('created-header')}}
    +
    {{item.created | utcToLocalTime | defaultValue}}
    +
    + +
    +
    {{t('comment-header')}}
    +
    {{item.comment}}
    +
    +
    +
    +
    +
    +
    diff --git a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.ts b/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.ts index 7efe74996..8105b43a6 100644 --- a/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.ts +++ b/UI/Web/src/app/admin/manage-scrobble-errors/manage-scrobble-errors.component.ts @@ -27,10 +27,11 @@ import {TranslocoLocaleModule} from "@jsverse/transloco-locale"; import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; import {ActionService} from "../../_services/action.service"; +import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component"; @Component({ selector: 'app-manage-scrobble-errors', - imports: [ReactiveFormsModule, FilterPipe, TranslocoModule, DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgxDatatableModule], + imports: [ReactiveFormsModule, FilterPipe, TranslocoModule, DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgxDatatableModule, ResponsiveTableComponent], templateUrl: './manage-scrobble-errors.component.html', styleUrls: ['./manage-scrobble-errors.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -60,6 +61,7 @@ export class ManageScrobbleErrorsComponent implements OnInit { formGroup = new FormGroup({ filter: new FormControl('', []) }); + trackBy = (index: number, item: ScrobbleError) => `${item.seriesId}`; ngOnInit() { diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html index f3cd8fd39..04eb9dff4 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.html @@ -151,43 +151,64 @@

    {{t('recurring-tasks-title')}}

    - - - - - {{t('job-title-header')}} - - - {{item.title | titlecase}} - - + @let data = (recurringTasks$ | async) ?? []; + + + + + {{t('job-title-header')}} + + + {{item.title | titlecase}} + + - - - {{t('last-executed-header')}} - - - {{item.lastExecutionUtc | utcToLocalTime | defaultValue }} - - + + + {{t('last-executed-header')}} + + + {{item.lastExecutionUtc | utcToLocalTime | defaultValue }} + + - - - {{t('cron-header')}} - - - {{item.cron}} - - - + + + {{t('cron-header')}} + + + {{item.cron}} + + + + +
    +
    +
    {{item.title | titlecase}}
    + +
    +
    +
    {{t('last-executed-header')}}
    +
    {{item.lastExecutionUtc | utcToLocalTime | defaultValue}}
    +
    + +
    +
    {{t('cron-header')}}
    +
    {{item.cron}}
    +
    +
    +
    +
    +
    + } diff --git a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts index 9d4fedb87..14c02b7c5 100644 --- a/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts +++ b/UI/Web/src/app/admin/manage-tasks-settings/manage-tasks-settings.component.ts @@ -35,6 +35,7 @@ import {SettingButtonComponent} from "../../settings/_components/setting-button/ import {DefaultModalOptions} from "../../_models/default-modal-options"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; import {AnnotationService} from "../../_services/annotation.service"; +import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component"; interface AdhocTask { name: string; @@ -49,9 +50,9 @@ interface AdhocTask { templateUrl: './manage-tasks-settings.component.html', styleUrls: ['./manage-tasks-settings.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ReactiveFormsModule, AsyncPipe, TitleCasePipe, DefaultValuePipe, - TranslocoModule, TranslocoLocaleModule, UtcToLocalTimePipe, SettingItemComponent, - SettingButtonComponent, NgxDatatableModule] + imports: [ReactiveFormsModule, AsyncPipe, TitleCasePipe, DefaultValuePipe, + TranslocoModule, TranslocoLocaleModule, UtcToLocalTimePipe, SettingItemComponent, + SettingButtonComponent, NgxDatatableModule, ResponsiveTableComponent] }) export class ManageTasksSettingsComponent implements OnInit { @@ -143,6 +144,7 @@ export class ManageTasksSettingsComponent implements OnInit { ]; customOption = 'custom'; + trackBy = (index: number, item: Job) => `${item.id}_${item.lastExecutionUtc}`; ngOnInit(): void { diff --git a/UI/Web/src/app/admin/manage-user-tokens/manage-user-tokens.component.html b/UI/Web/src/app/admin/manage-user-tokens/manage-user-tokens.component.html index 6c1844399..028dca128 100644 --- a/UI/Web/src/app/admin/manage-user-tokens/manage-user-tokens.component.html +++ b/UI/Web/src/app/admin/manage-user-tokens/manage-user-tokens.component.html @@ -1,8 +1,8 @@

    {{t('description')}}

    - - + + + +
    +
    +
    {{item.username}}
    + +
    +
    +
    {{t('anilist-header')}}
    +
    + @if (item.isAniListTokenSet) { + {{t('token-set-label')}} + {{t('expires-label', {date: item.aniListValidUntilUtc | utcToLocalTime})}} + } @else { + {{null | defaultValue}} + } +
    +
    + +
    +
    {{t('mal-header')}}
    +
    + @if (item.isMalTokenSet) { + {{t('token-set-label')}} + } @else { + {{null | defaultValue}} + } +
    +
    +
    +
    +
    +
    + +
    diff --git a/UI/Web/src/app/admin/manage-user-tokens/manage-user-tokens.component.ts b/UI/Web/src/app/admin/manage-user-tokens/manage-user-tokens.component.ts index e2b3ee3b0..1e41ed9c2 100644 --- a/UI/Web/src/app/admin/manage-user-tokens/manage-user-tokens.component.ts +++ b/UI/Web/src/app/admin/manage-user-tokens/manage-user-tokens.component.ts @@ -6,6 +6,7 @@ import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller"; import {UserTokenInfo} from "../../_models/kavitaplus/user-token-info"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; +import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component"; @Component({ selector: 'app-manage-user-tokens', @@ -14,7 +15,8 @@ import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; DefaultValuePipe, UtcToLocalTimePipe, VirtualScrollerModule, - NgxDatatableModule + NgxDatatableModule, + ResponsiveTableComponent ], templateUrl: './manage-user-tokens.component.html', styleUrl: './manage-user-tokens.component.scss', @@ -27,6 +29,7 @@ export class ManageUserTokensComponent implements OnInit { isLoading = true; users: UserTokenInfo[] = []; + trackBy = (idx: number, item: UserTokenInfo) => item; ngOnInit() { this.loadData(); diff --git a/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.html b/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.html deleted file mode 100644 index 1ddd97d8e..000000000 --- a/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.html +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @for(rowForm of items.controls; track rowForm; let idx = $index) { - - - - - - - - - - - - - - - - - - - - } @empty { - - } - -
    {{t('user-header')}}{{t('page-read-header')}}{{t('date-created-header')}}{{t('date-updated-header')}}
    - {{progressEvents[idx].userName | sentenceCase}} - - @if(editMode[idx]) { - - } @else { - {{progressEvents[idx].pagesRead}} - } - - {{progressEvents[idx].createdUtc | utcToLocalTime:'shortDate' | defaultDate}} - - {{progressEvents[idx].lastModifiedUtc | utcToLocalTime:'shortDate' | defaultDate}} -
    {{t('no-data')}}
    -
    diff --git a/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.ts b/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.ts deleted file mode 100644 index 7ba1d42ea..000000000 --- a/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.ts +++ /dev/null @@ -1,80 +0,0 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core'; -import {Chapter} from "../../_models/chapter"; -import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; -import {FullProgress} from "../../_models/readers/full-progress"; -import {ReaderService} from "../../_services/reader.service"; -import {TranslocoDirective} from "@jsverse/transloco"; -import {FormArray, FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms"; -import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe"; -import {DefaultDatePipe} from "../../_pipes/default-date.pipe"; -import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; - -@Component({ - selector: 'app-edit-chapter-progress', - imports: [ - UtcToLocalTimePipe, - TranslocoDirective, - ReactiveFormsModule, - SentenceCasePipe, - DefaultDatePipe, - NgxDatatableModule - ], - templateUrl: './edit-chapter-progress.component.html', - styleUrl: './edit-chapter-progress.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class EditChapterProgressComponent implements OnInit { - - private readonly readerService = inject(ReaderService); - private readonly cdRef = inject(ChangeDetectorRef); - private readonly fb = inject(FormBuilder); - - @Input({required: true}) chapter!: Chapter; - - progressEvents: Array = []; - editMode: {[key: number]: boolean} = {}; - formGroup = this.fb.group({ - items: this.fb.array([]) - }); - - get items() { - return this.formGroup.get('items') as FormArray; - } - - - ngOnInit() { - this.readerService.getAllProgressForChapter(this.chapter!.id).subscribe(res => { - this.progressEvents = res; - this.progressEvents.forEach((v, i) => { - this.editMode[i] = false; - this.items.push(this.createRowForm(v)); - }); - this.cdRef.markForCheck(); - }); - } - - createRowForm(progress: FullProgress): FormGroup { - return this.fb.group({ - pagesRead: [progress.pagesRead, [Validators.required, Validators.min(0), Validators.max(this.chapter!.pages)]], - created: [progress.createdUtc, [Validators.required]], - lastModified: [progress.lastModifiedUtc, [Validators.required]], - }); - } - - edit(progress: FullProgress, idx: number) { - this.editMode[idx] = !this.editMode[idx]; - this.cdRef.markForCheck(); - } - - save(progress: FullProgress, idx: number) { - // todo - this.editMode[idx] = !this.editMode[idx]; - // this.formGroup[idx + ''].patchValue({ - // pagesRead: progress.pagesRead, - // // Patch other form values as needed - // }); - this.cdRef.markForCheck(); - } - - protected readonly ColumnMode = ColumnMode; -} diff --git a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts index 29a79fd2c..2e6c47283 100644 --- a/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts +++ b/UI/Web/src/app/manga-reader/_components/infinite-scroller/infinite-scroller.component.ts @@ -9,7 +9,7 @@ import {WebtoonImage} from '../../_models/webtoon-image'; import {MangaReaderService} from '../../_service/manga-reader.service'; import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop"; import {TranslocoDirective} from "@jsverse/transloco"; -import {InfiniteScrollModule} from "ngx-infinite-scroll"; +import {InfiniteScrollDirective} from "ngx-infinite-scroll"; import {ReaderSetting} from "../../_models/reader-setting"; import {SafeStylePipe} from "../../../_pipes/safe-style.pipe"; import {UtilityService} from "../../../shared/_services/utility.service"; @@ -60,7 +60,7 @@ const enum DEBUG_MODES { templateUrl: './infinite-scroller.component.html', styleUrls: ['./infinite-scroller.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [AsyncPipe, TranslocoDirective, InfiniteScrollModule, SafeStylePipe] + imports: [AsyncPipe, TranslocoDirective, InfiniteScrollDirective, SafeStylePipe] }) export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit { private readonly document = inject(DOCUMENT); diff --git a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.scss b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.scss index 19a2ef5c4..c048a1a7d 100644 --- a/UI/Web/src/app/nav/_components/nav-header/nav-header.component.scss +++ b/UI/Web/src/app/nav/_components/nav-header/nav-header.component.scss @@ -142,3 +142,12 @@ .small-text { font-size: 0.8rem; } + +// Remove any hover code around the dropdown menu +:host ::ng-deep { + .dropdown-toggle:hover, .dropdown-toggle:active, .show { + background-color: transparent; + border-bottom-color: transparent; + box-shadow: none; + } +} diff --git a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html index c9cb71138..b909d14c5 100644 --- a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html +++ b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html @@ -78,6 +78,9 @@ @if (errors.invalidAsin) {
    {{t('invalid-asin')}}
    } + @if (errors.amazonCode) { +
    {{t('invalid-amazon-code')}}
    + } } diff --git a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts index 7a02bf7ee..84c411784 100644 --- a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts +++ b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts @@ -73,7 +73,7 @@ export class EditPersonModalComponent implements OnInit { editForm: FormGroup = new FormGroup({ name: new FormControl('', [Validators.required]), description: new FormControl('', []), - asin: new FormControl('', [], [this.asinValidator()]), + asin: new FormControl('', [], [this.amazonCodeValidator()]), aniListId: new FormControl('', []), malId: new FormControl('', []), hardcoverId: new FormControl('', []), @@ -205,20 +205,22 @@ export class EditPersonModalComponent implements OnInit { } } - asinValidator(): AsyncValidatorFn { + /** + * Validates that the string is a high probability of being an asin + */ + amazonCodeValidator(): AsyncValidatorFn { return (control: AbstractControl) => { const asin = control.value; if (!asin || asin.trim().length === 0) { return of(null); } - return this.personService.isValidAsin(asin).pipe(map(valid => { - if (valid) { - return null; - } + //https://stackoverflow.com/questions/2123131/determine-if-10-digit-string-is-valid-amazon-asin + if (!asin.toUpperCase().startsWith('B0') || !/^(B0|BT)[0-9A-Z]{8}$/.test(asin.toUpperCase())) { + return of({ 'amazonCode': {'asin': asin} } as ValidationErrors); + } - return { 'invalidAsin': {'asin': asin} } as ValidationErrors; - })); + return of(null); } } diff --git a/UI/Web/src/app/profile/_components/profile-stat-bar/profile-stat-bar.component.ts b/UI/Web/src/app/profile/_components/profile-stat-bar/profile-stat-bar.component.ts index 7a97f197f..77a6372af 100644 --- a/UI/Web/src/app/profile/_components/profile-stat-bar/profile-stat-bar.component.ts +++ b/UI/Web/src/app/profile/_components/profile-stat-bar/profile-stat-bar.component.ts @@ -36,9 +36,5 @@ export class ProfileStatBarComponent { data = model(); dataResource = this.statsService.getUserOverallStats(() => this.filter(), () => this.userId()); - constructor() { - - - } } diff --git a/UI/Web/src/app/profile/_components/profile/profile.component.html b/UI/Web/src/app/profile/_components/profile/profile.component.html index 4e444fdb9..85a0fea39 100644 --- a/UI/Web/src/app/profile/_components/profile/profile.component.html +++ b/UI/Web/src/app/profile/_components/profile/profile.component.html @@ -29,9 +29,23 @@ -
    +
    {{t('joined-label', {date: user.createdUtc | utcToLocalDate | timeAgo})}}
    + + @if (userStatsResource.hasValue() && userStatsResource.value()) { + @let data = userStatsResource.value(); +
    + {{t('last-read-label', {date: data.lastActiveUtc | utcToLocalDate | timeAgo | sentenceCase})}} +
    + +
    + {{t('time-reading-label', {date: data.timeSpentReading | timeDuration})}} +
    +
    + {{t('avg-reading-per-week-label', {time: data.avgHoursPerWeekSpentReading | timeDuration})}} +
    + }
    diff --git a/UI/Web/src/app/profile/_components/profile/profile.component.ts b/UI/Web/src/app/profile/_components/profile/profile.component.ts index cdee33fb8..8c59bee2e 100644 --- a/UI/Web/src/app/profile/_components/profile/profile.component.ts +++ b/UI/Web/src/app/profile/_components/profile/profile.component.ts @@ -27,6 +27,8 @@ import {ProfileReviewListComponent} from "../profile-review-list/profile-review- import {ProfileOverviewComponent} from "../profile-overview/profile-overview.component"; import {CompactNumberPipe} from "../../../_pipes/compact-number.pipe"; import {ProfileStatsComponent} from "../profile-stats/profile-stats.component"; +import {SentenceCasePipe} from "../../../_pipes/sentence-case.pipe"; +import {TimeDurationPipe} from "../../../_pipes/time-duration.pipe"; enum TabID { Overview = 'overview-tab', @@ -56,6 +58,8 @@ enum TabID { ProfileOverviewComponent, CompactNumberPipe, ProfileStatsComponent, + SentenceCasePipe, + TimeDurationPipe, ], templateUrl: './profile.component.html', styleUrl: './profile.component.scss', @@ -77,6 +81,8 @@ export class ProfileComponent { protected readonly totalReadsResource = this.statsService.getTotalReads(() => this.userId()); + protected readonly userStatsResource = this.statsService.getUserStatisticsResource(() => this.userId()); + activeTabId = TabID.Overview; diff --git a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts index 818b2710a..299c7d26a 100644 --- a/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/_components/series-detail/series-detail.component.ts @@ -30,7 +30,7 @@ import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import {ToastrService} from 'ngx-toastr'; -import {catchError, debounceTime, firstValueFrom, forkJoin, Observable, of, ReplaySubject, switchMap, tap} from 'rxjs'; +import {catchError, debounceTime, forkJoin, Observable, of, ReplaySubject, tap} from 'rxjs'; import {map} from 'rxjs/operators'; import {BulkSelectionService} from 'src/app/cards/bulk-selection.service'; import { @@ -67,7 +67,7 @@ import {ExternalSeriesCardComponent} from '../../../cards/external-series-card/e import {SeriesCardComponent} from '../../../cards/series-card/series-card.component'; import {VirtualScrollerModule} from '@iharbeck/ngx-virtual-scroller'; import {BulkOperationsComponent} from '../../../cards/bulk-operations/bulk-operations.component'; -import {translate, TranslocoDirective, TranslocoService} from "@jsverse/transloco"; +import {translate, TranslocoDirective} from "@jsverse/transloco"; import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component"; import {PublicationStatus} from "../../../_models/metadata/publication-status"; import {NextExpectedChapter} from "../../../_models/series-detail/next-expected-chapter"; @@ -136,10 +136,10 @@ interface StoryLineItem { } @Component({ - selector: 'app-series-detail', - templateUrl: './series-detail.component.html', - styleUrls: ['./series-detail.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'app-series-detail', + templateUrl: './series-detail.component.html', + styleUrls: ['./series-detail.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, imports: [CardActionablesComponent, ReactiveFormsModule, NgStyle, NgbTooltip, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, BulkOperationsComponent, @@ -170,7 +170,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { private readonly collectionTagService = inject(CollectionTagService); private readonly cdRef = inject(ChangeDetectorRef); private readonly scrollService = inject(ScrollService); - private readonly translocoService = inject(TranslocoService); private readonly readingProfileService = inject(ReadingProfileService); protected readonly bulkSelectionService = inject(BulkSelectionService); protected readonly utilityService = inject(UtilityService); @@ -501,7 +500,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { if (event.event === EVENTS.SeriesRemoved) { const seriesRemovedEvent = event.payload as SeriesRemovedEvent; if (seriesRemovedEvent.seriesId === this.seriesId) { - this.toastr.info(this.translocoService.translate('errors.series-doesnt-exist')); + this.toastr.info(translate('errors.series-doesnt-exist')); this.router.navigateByUrl('/home'); } } else if (event.event === EVENTS.ScanSeries) { @@ -629,7 +628,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { break; case Action.ClearReadingProfile: this.readingProfileService.clearSeriesProfiles(this.seriesId).subscribe(() => { - this.toastr.success(this.translocoService.translate('actionable.cleared-profile')); + this.toastr.success(translate('actionable.cleared-profile')); }); break; default: @@ -1065,17 +1064,14 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked { read(incognitoMode: boolean = false) { if (this.bulkSelectionService.hasSelections()) return; - this.readerService.readSeries(this.series()!, incognitoMode, (chapter) => { - this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'chapter', chapter.id]); - }); + this.readerService.readSeries(this.series()!, incognitoMode); } openChapter(chapter: Chapter, incognitoMode = false, promptForReread: boolean = true) { if (this.bulkSelectionService.hasSelections()) return; this.router.navigate(['library', this.libraryId, 'series', this.seriesId, 'chapter', chapter.id]); - this.readerService.readChapter(this.libraryId, this.seriesId, chapter, incognitoMode, promptForReread); - + this.readerService.readChapter(this.libraryId, this.seriesId, chapter, incognitoMode); } diff --git a/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.html b/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.html index b0dd0dbff..61aeba1b1 100644 --- a/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.html +++ b/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.html @@ -2,7 +2,12 @@

    {{title()}}

    - {{tooltip()}} + @if (tooltip(); as tooltipValue) { + {{tooltipValue}} + } + @if (warning(); as warningValue) { + {{warningValue}} + }
    @if (!isLoading() && options().length > 0) { diff --git a/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.ts b/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.ts index 1e899021c..6b41f549b 100644 --- a/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.ts +++ b/UI/Web/src/app/settings/_components/setting-multi-check-box/setting-multi-check-box.component.ts @@ -90,6 +90,11 @@ export class SettingMultiCheckBox implements ControlValueAccessor { * Disable all checkboxes */ disabled = model(false); + /** + * An optional warning to display underneath the title + * @optional + */ + warning = input(undefined); isLoading = computed(() => { const loading = this.loading(); diff --git a/UI/Web/src/app/shared/_components/responsive-table/responsive-table.component.html b/UI/Web/src/app/shared/_components/responsive-table/responsive-table.component.html new file mode 100644 index 000000000..7c9fc8f8c --- /dev/null +++ b/UI/Web/src/app/shared/_components/responsive-table/responsive-table.component.html @@ -0,0 +1,14 @@ +@if (showCards()) { +
    + +
    + @for (item of rows(); track trackByFn()(idx, item); let idx = $index) { + + + } +
    +
    +} @else { + +} diff --git a/UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.scss b/UI/Web/src/app/shared/_components/responsive-table/responsive-table.component.scss similarity index 100% rename from UI/Web/src/app/cards/edit-chapter-progress/edit-chapter-progress.component.scss rename to UI/Web/src/app/shared/_components/responsive-table/responsive-table.component.scss diff --git a/UI/Web/src/app/shared/_components/responsive-table/responsive-table.component.ts b/UI/Web/src/app/shared/_components/responsive-table/responsive-table.component.ts new file mode 100644 index 000000000..c3f70a956 --- /dev/null +++ b/UI/Web/src/app/shared/_components/responsive-table/responsive-table.component.ts @@ -0,0 +1,42 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + contentChild, + inject, + input, + TemplateRef, + TrackByFunction +} from '@angular/core'; +import {Breakpoint, UtilityService} from "../../_services/utility.service"; +import {NgTemplateOutlet} from "@angular/common"; + + +/** + * A one-stop shop to provide a responsive experience + */ +@Component({ + selector: 'app-responsive-table', + imports: [ + NgTemplateOutlet + ], + templateUrl: './responsive-table.component.html', + styleUrl: './responsive-table.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ResponsiveTableComponent { + private readonly utilityService = inject(UtilityService); + + rows = input.required(); + breakpoint = input(Breakpoint.Mobile); + trackByFn = input>((index, item: any) => item?.id ?? index); + + private cardTemplateSignal = contentChild>('cardTemplate'); + protected readonly cardTemplateRef = computed(() => this.cardTemplateSignal()); + + protected readonly showCards = computed(() => { + const activeBreakpoint = this.utilityService.activeBreakpointSignal(); + const setting = this.breakpoint(); + return activeBreakpoint && activeBreakpoint <= setting; + }); +} diff --git a/UI/Web/src/app/shared/_services/utility.service.ts b/UI/Web/src/app/shared/_services/utility.service.ts index 65353853e..035ce1c93 100644 --- a/UI/Web/src/app/shared/_services/utility.service.ts +++ b/UI/Web/src/app/shared/_services/utility.service.ts @@ -1,5 +1,5 @@ import {HttpParams} from '@angular/common/http'; -import { Injectable, signal, Signal, inject } from '@angular/core'; +import {inject, Injectable, signal} from '@angular/core'; import {Chapter} from 'src/app/_models/chapter'; import {LibraryType} from 'src/app/_models/library/library'; import {MangaFormat} from 'src/app/_models/manga-format'; @@ -10,6 +10,7 @@ import {translate} from "@jsverse/transloco"; import {debounceTime, ReplaySubject, shareReplay} from "rxjs"; import {DOCUMENT} from "@angular/common"; import getComputedStyle from "@popperjs/core/lib/dom-utils/getComputedStyle"; +import {toSignal} from "@angular/core/rxjs-interop"; export enum KEY_CODES { RIGHT_ARROW = 'ArrowRight', @@ -73,6 +74,7 @@ export class UtilityService { public readonly activeBreakpointSource = new ReplaySubject(1); public readonly activeBreakpoint$ = this.activeBreakpointSource.asObservable().pipe(debounceTime(60), shareReplay({bufferSize: 1, refCount: true})); + public readonly activeBreakpointSignal = toSignal(this.activeBreakpointSource); /** * The currently active breakpoint, is {@link UserBreakpoint.Never} until the app has loaded diff --git a/UI/Web/src/app/sidenav/_components/customize-dashboard-streams/customize-dashboard-streams.component.scss b/UI/Web/src/app/sidenav/_components/customize-dashboard-streams/customize-dashboard-streams.component.scss index 6c4f625c0..7ffc55229 100644 --- a/UI/Web/src/app/sidenav/_components/customize-dashboard-streams/customize-dashboard-streams.component.scss +++ b/UI/Web/src/app/sidenav/_components/customize-dashboard-streams/customize-dashboard-streams.component.scss @@ -1,5 +1,5 @@ ::ng-deep .drag-handle { - margin-top: 100% !important; + margin-top: 70% !important; } app-dashboard-stream-list-item { diff --git a/UI/Web/src/app/sidenav/_components/customize-sidenav-streams/customize-sidenav-streams.component.scss b/UI/Web/src/app/sidenav/_components/customize-sidenav-streams/customize-sidenav-streams.component.scss index a0eaf86ed..b7d9c9f99 100644 --- a/UI/Web/src/app/sidenav/_components/customize-sidenav-streams/customize-sidenav-streams.component.scss +++ b/UI/Web/src/app/sidenav/_components/customize-sidenav-streams/customize-sidenav-streams.component.scss @@ -1,5 +1,5 @@ ::ng-deep .drag-handle { - margin-top: 100% !important; + margin-top: 70% !important; } app-sidenav-stream-list-item { diff --git a/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.html b/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.html index 102f66941..14cf37e43 100644 --- a/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.html +++ b/UI/Web/src/app/sidenav/_components/sidenav-stream-list-item/sidenav-stream-list-item.component.html @@ -14,7 +14,7 @@ {{t('remove')}} @if (item.streamType===SideNavStreamType.SmartFilter) { - diff --git a/UI/Web/src/app/statistics/_components/activity-graph/activity-graph.component.html b/UI/Web/src/app/statistics/_components/activity-graph/activity-graph.component.html index 992b35b6e..1c8ce2ce4 100644 --- a/UI/Web/src/app/statistics/_components/activity-graph/activity-graph.component.html +++ b/UI/Web/src/app/statistics/_components/activity-graph/activity-graph.component.html @@ -82,7 +82,6 @@
    -
    {{t('no-activity-tooltip', {date: item.date | utcToLocalDate | ordinalDate})}} diff --git a/UI/Web/src/app/statistics/_components/library-and-time-selector/library-and-time-selector.component.html b/UI/Web/src/app/statistics/_components/library-and-time-selector/library-and-time-selector.component.html index ecdd4f75a..eb76774d5 100644 --- a/UI/Web/src/app/statistics/_components/library-and-time-selector/library-and-time-selector.component.html +++ b/UI/Web/src/app/statistics/_components/library-and-time-selector/library-and-time-selector.component.html @@ -1,35 +1,38 @@ -
    -

    {{label()}}

    - @if (filterForm().get('libraries'); as control) { + +
    +

    {{label()}}

    - @if (!showLibraryTypeahead()) { -

    - @if (allLibraries().length === control.value?.length) { - Kavita - } @else { - @for (lib of (control.value ?? []); track lib) { - {{libraryName(lib)}} - @if (!$last) {,} + @if (filterForm().get('libraries'); as control) { + @if (!showLibraryTypeahead()) { +

    + @if (allLibraries().length === control.value?.length) { + Kavita + } @else { + @for (lib of (control.value ?? []); track lib) { + {{libraryName(lib)}} + @if (!$last) {,} + } } - } -

    + + } + + @if (showLibraryTypeahead() && libraryTypeaheadSettings) { + + + {{lib.name}} + + + {{lib.name}} + + + } } - @if (showLibraryTypeahead() && libraryTypeaheadSettings) { - - - {{lib.name}} - - - {{lib.name}} - - - } - } - - - + +
    -
    diff --git a/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.html b/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.html index 2ed0656fb..8437aa840 100644 --- a/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.html +++ b/UI/Web/src/app/statistics/_components/user-stats/user-stats.component.html @@ -1,21 +1,24 @@ @if (userId) { +
    @if (userStats$ | async; as userStats) { + [chaptersRead]="userStats.chaptersRead" [lastActive]="userStats.lastActiveUtc" [avgHoursPerWeekSpentReading]="userStats.avgHoursPerWeekSpentReading" /> }
    -
    - -
    -
    - -
    -
    - -
    + + + + + + + + +
    } diff --git a/UI/Web/src/app/statistics/_models/user-read-statistics.ts b/UI/Web/src/app/statistics/_models/user-read-statistics.ts index 81d2b5943..ee94f3590 100644 --- a/UI/Web/src/app/statistics/_models/user-read-statistics.ts +++ b/UI/Web/src/app/statistics/_models/user-read-statistics.ts @@ -1,11 +1,11 @@ -import { StatCount } from "./stat-count"; +import {StatCount} from "./stat-count"; export interface UserReadStatistics { totalPagesRead: number; totalWordsRead: number; timeSpentReading: number; chaptersRead: number; - lastActive: string; + lastActiveUtc: string; avgHoursPerWeekSpentReading: number; percentReadPerLibrary: Array>; -} \ No newline at end of file +} diff --git a/UI/Web/src/app/user-settings/_modals/create-auth-key/create-auth-key.component.ts b/UI/Web/src/app/user-settings/_modals/create-auth-key/create-auth-key.component.ts index 8d11bc157..5b6295e25 100644 --- a/UI/Web/src/app/user-settings/_modals/create-auth-key/create-auth-key.component.ts +++ b/UI/Web/src/app/user-settings/_modals/create-auth-key/create-auth-key.component.ts @@ -29,7 +29,7 @@ export class CreateAuthKeyComponent implements OnInit { settingsForm: FormGroup = new FormGroup({ name: new FormControl('', [Validators.required]), - keyLength: new FormControl(8, [Validators.required]), + keyLength: new FormControl(8, [Validators.required, Validators.min(8), Validators.max(32)]), expiresUtc: new FormControl('', []), }); diff --git a/UI/Web/src/app/user-settings/manage-auth-keys/manage-auth-keys.component.html b/UI/Web/src/app/user-settings/manage-auth-keys/manage-auth-keys.component.html index 7b5b2fcf7..0f3f4fddc 100644 --- a/UI/Web/src/app/user-settings/manage-auth-keys/manage-auth-keys.component.html +++ b/UI/Web/src/app/user-settings/manage-auth-keys/manage-auth-keys.component.html @@ -19,7 +19,7 @@

    {{t('auth-keys-title')}} @if (!isReadOnly()) { - @@ -30,74 +30,125 @@ @let authKeysValue = authKeys(); @if (authKeysValue) { - - - - {{t('name-label')}} - - - @if (item.provider === AuthKeyProvider.System) { - {{ t(item.name) }} - } @else { - {{ item.name }} - } - - + + + + + {{t('name-label')}} + + + @if (item.provider === AuthKeyProvider.System) { + {{ t(item.name) }} + } @else { + {{ item.name }} + } + + - - - {{t('key-label')}} - - - - - - + + + {{t('key-label')}} + + + + + + - - - {{t('expires-label')}} - - - {{ item.expiresAtUtc | utcToLocalDate | date:'mediumDate' | defaultDate }} - - + + + {{t('expires-label')}} + + + {{ item.expiresAtUtc | utcToLocalDate | date:'mediumDate' | defaultDate }} + + - - - {{t('last-accessed-label')}} - - - {{ item.lastAccessedAtUtc | utcToLocalDate | defaultDate }} - - + + + {{t('last-accessed-label')}} + + + {{ item.lastAccessedAtUtc | utcToLocalDate | defaultDate }} + + - - - {{t('actions-label')}} - - - - - - - + + + {{t('actions-label')}} + + + + + + + + + +
    +
    +
    + @if (item.provider === AuthKeyProvider.System) { + {{ t(item.name) }} + } @else { + {{ item.name }} + } +
    + +
    + +
    + + +
    +
    + +
    +
    + + {{ item.expiresAtUtc | utcToLocalDate | date:'mediumDate' | defaultDate }} +
    +
    + + {{ item.lastAccessedAtUtc | utcToLocalDate | defaultDate }} +
    +
    + + +
    + + +
    +
    +
    +
    + } diff --git a/UI/Web/src/app/user-settings/manage-auth-keys/manage-auth-keys.component.ts b/UI/Web/src/app/user-settings/manage-auth-keys/manage-auth-keys.component.ts index c5208a92d..eb141ad60 100644 --- a/UI/Web/src/app/user-settings/manage-auth-keys/manage-auth-keys.component.ts +++ b/UI/Web/src/app/user-settings/manage-auth-keys/manage-auth-keys.component.ts @@ -16,6 +16,8 @@ import {CreateAuthKeyComponent} from "../_modals/create-auth-key/create-auth-key import {Clipboard} from "@angular/cdk/clipboard"; import {DatePipe} from "@angular/common"; import {ToastrService} from "ngx-toastr"; +import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component"; +import {Breakpoint} from "../../shared/_services/utility.service"; @Component({ selector: 'app-manage-auth-keys', @@ -27,6 +29,7 @@ import {ToastrService} from "ngx-toastr"; DefaultDatePipe, ToggleVisibilityDirective, DatePipe, + ResponsiveTableComponent, ], templateUrl: './manage-auth-keys.component.html', @@ -44,20 +47,27 @@ export class ManageAuthKeysComponent implements OnInit { protected readonly opdsUrlLink = `Wiki` + refreshData = model(); isReadOnly = this.accountService.isReadOnly; opdsUrl = signal(''); - authKeys = signal(null); + authKeys = computed(() => { + const _ = this.refreshData(); + const account = this.accountService.currentUserSignal(); + if (!account) return null; + + return account.authKeys; + }); + trackByAuthKey = (index: number, item: AuthKey) => `${item.id}_${item.key}_${item.name}`; makeUrl: (val: string) => string = (val: string) => { return this.opdsUrl(); }; protected readonly isOpdsEnabledResource = this.settingsService.getOpdsEnabledResource(); ngOnInit() { - this.loadAuthKeys(); + this.refreshOpdsUrl(); } - loadAuthKeys() { - this.accountService.getAuthKeys().subscribe(authKeys => this.authKeys.set(authKeys)); + refreshOpdsUrl() { this.accountService.getOpdsUrl().subscribe(res => this.opdsUrl.set(res)); } @@ -66,8 +76,8 @@ export class ManageAuthKeysComponent implements OnInit { ref.closed.subscribe((result: AuthKey | null) => { if (result === null) return; - - this.loadAuthKeys(); + this.refreshData.update(x => !x); + this.refreshOpdsUrl(); }); } @@ -78,7 +88,8 @@ export class ManageAuthKeysComponent implements OnInit { ref.closed.subscribe((result: AuthKey | null) => { if (result === null) return; - this.loadAuthKeys(); + this.refreshData.update(x => !x); + this.refreshOpdsUrl(); }); } @@ -87,7 +98,8 @@ export class ManageAuthKeysComponent implements OnInit { return; } this.accountService.deleteAuthKey(authKey.id).subscribe(res => { - this.loadAuthKeys(); + this.refreshData.update(x => !x); + this.refreshOpdsUrl(); }) } @@ -98,4 +110,5 @@ export class ManageAuthKeysComponent implements OnInit { protected readonly ColumnMode = ColumnMode; protected readonly AuthKeyProvider = AuthKeyProvider; + protected readonly Breakpoint = Breakpoint; } diff --git a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html index 673d04308..d66415850 100644 --- a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html +++ b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.html @@ -18,58 +18,93 @@

    } - + + - - - {{t('name-label')}} - - - {{ item.name }} - - + + + {{t('name-label')}} + + + {{ item.name }} + + - - - {{t('email-label')}} - - - {{ item.emailAddress }} - - + + + {{t('email-label')}} + + + {{ item.emailAddress }} + + - - - {{t('platform-label')}} - - - {{ item.platform | devicePlatform }} - - + + + {{t('platform-label')}} + + + {{ item.platform | devicePlatform }} + + - - - - + + + + - - - - + +
    + + + + +
    +
    +
    +
    {{item.name}}
    +
    + + +
    +
    + +
    +
    +
    {{t('email-label')}}
    +
    {{item.emailAddress}}
    +
    + +
    +
    {{t('platform-label')}}
    +
    {{item.platform | devicePlatform}}
    +
    +
    +
    +
    +
    + +
    diff --git a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts index c341fa692..ad069701c 100644 --- a/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts +++ b/UI/Web/src/app/user-settings/manage-devices/manage-devices.component.ts @@ -17,13 +17,14 @@ import {AsyncPipe} from "@angular/common"; import {ClientDevice} from "../../_models/client-device"; import {ClientDeviceCardComponent} from "../../_single-module/client-device-card/client-device-card.component"; import {LoadingComponent} from "../../shared/loading/loading.component"; +import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component"; @Component({ selector: 'app-manage-devices', templateUrl: './manage-devices.component.html', styleUrls: ['./manage-devices.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [DevicePlatformPipe, TranslocoDirective, AsyncPipe, NgxDatatableModule, ClientDeviceCardComponent, LoadingComponent] + imports: [DevicePlatformPipe, TranslocoDirective, AsyncPipe, NgxDatatableModule, ClientDeviceCardComponent, LoadingComponent, ResponsiveTableComponent] }) export class ManageDevicesComponent implements OnInit { @@ -39,6 +40,7 @@ export class ManageDevicesComponent implements OnInit { isEditingDevice: boolean = false; device: Device | undefined; hasEmailSetup = false; + trackBy = (idx: number, item: Device) => `${item.name}_${item.emailAddress}_${item.platform}_${item.lastUsed}`; clientDevices = model([]); diff --git a/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.html b/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.html index c6618710a..be7b63640 100644 --- a/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.html +++ b/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.html @@ -1,41 +1,68 @@

    {{t('description')}}

    - + + - - - {{t('series-name-header')}} - - - - {{item.seriesName}} - - + + + {{t('series-name-header')}} + + + + {{item.seriesName}} + + - - - {{t('created-header')}} - - - {{item.createdUtc | utcToLocalTime}} - - + + + {{t('created-header')}} + + + {{item.createdUtc | utcToLocalTime}} + + - - - - - - - + + + + + + + + + +
    +
    +
    + +
    +
    + + {{item.seriesName}} + +
    +
    {{item.createdUtc | utcToLocalTime}}
    +
    + +
    +
    +
    +
    +
    diff --git a/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.ts b/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.ts index 7b701f784..96f026e4c 100644 --- a/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.ts +++ b/UI/Web/src/app/user-settings/user-holds/scrobbling-holds.component.ts @@ -7,10 +7,11 @@ import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe"; import {ScrobbleHold} from "../../_models/scrobbling/scrobble-hold"; import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable"; import {APP_BASE_HREF} from "@angular/common"; +import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component"; @Component({ selector: 'app-user-holds', - imports: [TranslocoDirective, ImageComponent, UtcToLocalTimePipe, NgxDatatableModule], + imports: [TranslocoDirective, ImageComponent, UtcToLocalTimePipe, NgxDatatableModule, ResponsiveTableComponent], templateUrl: './scrobbling-holds.component.html', styleUrls: ['./scrobbling-holds.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -25,6 +26,7 @@ export class ScrobblingHoldsComponent { isLoading = true; data: Array = []; + trackBy = (idx: number, item: ScrobbleHold) => `${item.seriesId}_${idx}`; constructor() { this.loadData(); diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index c90267d4f..e19c52686 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -85,7 +85,8 @@ "opds": "OPDS", "image-only": "Images", "add-auth-key": "New", - "description": "An Auth Key is like a password. Rotating it will invalidate any existing clients." + "description": "An Auth Key is like a password. Rotating it will invalidate any existing clients.", + "copy-key": "{{common.copy}}" }, "create-auth-key": { @@ -124,6 +125,7 @@ "required": "{{validation.required-field}}", "email": "{{common.email}}", "invalid-email-warning": "A non-valid email will block some functionalities of Kavita", + "warning-read-only": "Read Only accounts have limited permissions", "cancel": "{{common.cancel}}", "saving": "Saving…", "update": "Update", @@ -294,8 +296,8 @@ "reread-modal": { "title": "Re-Read", - "description-full-read": "You've already finished this {{entityType}}! Would you like to start over?", - "description-time-passed": "It has been {{days}} days since you've last read this {{entityType}}, need a refresher?", + "description-full-read": "You've already finished {{name}}! Would you like to start over?", + "description-time-passed": "It has been {{days}} days since you've last read {{name}}, need a refresher?", "continue": "Pick up where you left off", "reread": "Start Over", "cancel": "{{common.cancel}}", @@ -1268,7 +1270,7 @@ "no-progress-incognito": "Read incognito", "progress": "Continue", "progress-incognito": "Continue incognito", - "full-read": "Reread", + "full-read": "Re-Read", "full-read-incognito": "Reread incognito" }, @@ -2338,16 +2340,6 @@ "track": "Track" }, - "edit-chapter-progress": { - "user-header": "User", - "page-read-header": "Pages Read", - "date-created-header": "Created (UTC)", - "date-updated-header": "Last Updated (UTC)", - "action-header": "{{common.edit}}", - "edit-alt": "{{common.edit}}", - "no-data": "{{user-scrobble-history.no-data}}" - }, - "import-mappings": { "import-step": "Import", "configure-step": "Configure", @@ -2674,7 +2666,6 @@ "field-locked-alt": "{{edit-series-modal.field-locked-alt}}", "cover-image-tab": "{{tabs.cover-tab}}", "tasks-tab": "{{tabs.tasks-tab}}", - "progress-tab": "{{tabs.progress-tab}}", "info-tab": "{{tabs.info-tab}}", "cover-image-description": "{{edit-series-modal.cover-image-description}}", "tags-tab": "{{tabs.tags-tab}}", @@ -2699,7 +2690,6 @@ "title": "Edit", "close": "{{common.close}}", "save": "{{common.save}}", - "progress-tab": "{{tabs.progress-tab}}", "cover-image-tab": "{{tabs.cover-tab}}", "tasks-tab": "{{tabs.tasks-tab}}", "info-tab": "{{tabs.info-tab}}", @@ -2739,7 +2729,7 @@ "hardcover-tooltip": "https://hardcover.app/authors/{HardcoverId}", "asin-label": "ASIN", "asin-tooltip": "https://www.amazon.com/stores/J.K.-Rowling/author/{ASIN}", - "invalid-asin": "ASIN must be a valid ISBN-10 or ISBN-13 format", + "invalid-amazon-code": "ASIN should start with B0 or BT", "description-label": "Description", "required-field": "{{validation.required-field}}", "cover-image-description": "{{edit-series-modal.cover-image-description}}", @@ -2883,6 +2873,9 @@ "profile": { "title": "{{username}}'s Profile", "joined-label": "Joined: {{date}}", + "last-read-label": "Last Read: {{date}}", + "time-reading-label": "Total Read Time: {{date}}", + "avg-reading-per-week-label": "Avg Per Week: {{time}}", "stats-title": "A look at {{username}}'s journey through {{libraryName}}", "overview-tab": "{{tabs.overview-tab}}", "stats-tab": "{{tabs.stats-tab}}",