diff --git a/.gitignore b/.gitignore index 319923287..f2ae835bf 100644 --- a/.gitignore +++ b/.gitignore @@ -508,6 +508,7 @@ UI/Web/dist/ /API/config/cache/ /API/config/temp/ /API/config/stats/ +/API/config/bookmarks/ /API/config/kavita.db /API/config/kavita.db-shm /API/config/kavita.db-wal diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index 9e0135612..c91ba4671 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -10,7 +10,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/API.Tests/Parser/ComicParserTests.cs b/API.Tests/Parser/ComicParserTests.cs index a4fe08381..aa7b01294 100644 --- a/API.Tests/Parser/ComicParserTests.cs +++ b/API.Tests/Parser/ComicParserTests.cs @@ -78,6 +78,7 @@ namespace API.Tests.Parser [InlineData("Chevaliers d'Héliopolis T3 - Rubedo, l'oeuvre au rouge (Jodorowsky & Jérémy)", "Chevaliers d'Héliopolis")] [InlineData("Bd Fr-Aldebaran-Antares-t6", "Aldebaran-Antares")] [InlineData("Tintin - T22 Vol 714 pour Sydney", "Tintin")] + [InlineData("Fables 2010 Vol. 1 Legends in Exile", "Fables 2010")] public void ParseComicSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseComicSeries(filename)); diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs index 95470db1a..0915c53fb 100644 --- a/API.Tests/Services/DirectoryServiceTests.cs +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -379,9 +379,10 @@ namespace API.Tests.Services { const string testDirectory = "c:/manga/"; var fileSystem = new MockFileSystem(); + fileSystem.AddDirectory(testDirectory); var ds = new DirectoryService(Substitute.For>(), fileSystem); - Assert.False(ds.IsDirectoryEmpty("c:/manga/")); + Assert.True(ds.IsDirectoryEmpty("c:/manga/")); } [Fact] @@ -733,6 +734,25 @@ namespace API.Tests.Services Assert.True(fileSystem.Directory.Exists($"{testDirectory}subdir/")); } + #endregion + + #region CheckWriteAccess + + [Fact] + public async Task CheckWriteAccess_ShouldHaveAccess() + { + const string testDirectory = "/manga/"; + var fileSystem = new MockFileSystem(); + + var ds = new DirectoryService(Substitute.For>(), fileSystem); + var hasAccess = await ds.CheckWriteAccess(ds.FileSystem.Path.Join(testDirectory, "bookmarks")); + Assert.True(hasAccess); + + Assert.False(ds.FileSystem.Directory.Exists(ds.FileSystem.Path.Join(testDirectory, "bookmarks"))); + Assert.False(ds.FileSystem.File.Exists(ds.FileSystem.Path.Join(testDirectory, "bookmarks", "test.txt"))); + } + + #endregion } } diff --git a/API/API.csproj b/API/API.csproj index 06a00518a..341e4ddb4 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -70,7 +70,7 @@ - + diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index d39985df6..adb2732eb 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -136,10 +136,10 @@ namespace API.Controllers public async Task DownloadBookmarkPages(DownloadBookmarkDto downloadBookmarkDto) { // We know that all bookmarks will be for one single seriesId + var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(downloadBookmarkDto.Bookmarks.First().SeriesId); - var totalFilePaths = new List(); - var tempFolder = $"download_{series.Id}_bookmarks"; + var tempFolder = $"download_{user.Id}_{series.Id}_bookmarks"; var fullExtractPath = Path.Join(_directoryService.TempDirectory, tempFolder); if (_directoryService.FileSystem.DirectoryInfo.FromDirectoryName(fullExtractPath).Exists) { @@ -148,42 +148,14 @@ namespace API.Controllers } _directoryService.ExistOrCreate(fullExtractPath); - var uniqueChapterIds = downloadBookmarkDto.Bookmarks.Select(b => b.ChapterId).Distinct().ToList(); + var bookmarkDirectory = + (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; + var files = (await _unitOfWork.UserRepository.GetAllBookmarksByIds(downloadBookmarkDto.Bookmarks + .Select(b => b.Id) + .ToList())) + .Select(b => _directoryService.FileSystem.Path.Join(bookmarkDirectory, b.FileName)); - foreach (var chapterId in uniqueChapterIds) - { - var chapterExtractPath = Path.Join(fullExtractPath, $"{series.Id}_bookmark_{chapterId}"); - var chapterPages = downloadBookmarkDto.Bookmarks.Where(b => b.ChapterId == chapterId) - .Select(b => b.Page).ToList(); - var mangaFiles = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); - switch (series.Format) - { - case MangaFormat.Image: - _directoryService.ExistOrCreate(chapterExtractPath); - _directoryService.CopyFilesToDirectory(mangaFiles.Select(f => f.FilePath), chapterExtractPath, $"{chapterId}_"); - break; - case MangaFormat.Archive: - case MangaFormat.Pdf: - _cacheService.ExtractChapterFiles(chapterExtractPath, mangaFiles.ToList()); - var originalFiles = _directoryService.GetFilesWithExtension(chapterExtractPath, - Parser.Parser.ImageFileExtensions); - _directoryService.CopyFilesToDirectory(originalFiles, chapterExtractPath, $"{chapterId}_"); - _directoryService.DeleteFiles(originalFiles); - break; - case MangaFormat.Epub: - return BadRequest("Series is not in a valid format."); - default: - return BadRequest("Series is not in a valid format. Please rescan series and try again."); - } - - var files = _directoryService.GetFilesWithExtension(chapterExtractPath, Parser.Parser.ImageFileExtensions); - // Filter out images that aren't in bookmarks - Array.Sort(files, _numericComparer); - totalFilePaths.AddRange(files.Where((_, i) => chapterPages.Contains(i))); - } - - - var (fileBytes, _) = await _archiveService.CreateZipForDownload(totalFilePaths, + var (fileBytes, _) = await _archiveService.CreateZipForDownload(files, tempFolder); _directoryService.ClearAndDeleteDirectory(fullExtractPath); return File(fileBytes, DefaultContentType, $"{series.Name} - Bookmarks.zip"); diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index bbec90b23..a875be14e 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -1,6 +1,7 @@ using System.IO; using System.Threading.Tasks; using API.Data; +using API.Entities.Enums; using API.Extensions; using API.Services; using Microsoft.AspNetCore.Mvc; @@ -85,5 +86,27 @@ namespace API.Controllers Response.AddCacheHeader(path); return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path)); } + + /// + /// Returns image for a given bookmark page + /// + /// This request is served unauthenticated, but user must be passed via api key to validate + /// + /// Starts at 0 + /// API Key for user. Needed to authenticate request + /// + [HttpGet("bookmark")] + public async Task GetBookmarkImage(int chapterId, int pageNum, string apiKey) + { + var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey); + var bookmark = await _unitOfWork.UserRepository.GetBookmarkForPage(pageNum, chapterId, userId); + if (bookmark == null) return BadRequest("Bookmark does not exist"); + + var bookmarkDirectory = + (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; + var file = new FileInfo(Path.Join(bookmarkDirectory, bookmark.FileName)); + var format = Path.GetExtension(file.FullName).Replace(".", ""); + return PhysicalFile(file.FullName, "image/" + format, Path.GetFileName(file.FullName)); + } } } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 638ef5e6d..928d05e17 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -8,8 +8,10 @@ using API.Data.Repositories; using API.DTOs; using API.DTOs.Reader; using API.Entities; +using API.Entities.Enums; using API.Extensions; using API.Services; +using API.Services.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -24,15 +26,21 @@ namespace API.Controllers private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IReaderService _readerService; + private readonly IDirectoryService _directoryService; + private readonly ICleanupService _cleanupService; /// public ReaderController(ICacheService cacheService, - IUnitOfWork unitOfWork, ILogger logger, IReaderService readerService) + IUnitOfWork unitOfWork, ILogger logger, + IReaderService readerService, IDirectoryService directoryService, + ICleanupService cleanupService) { _cacheService = cacheService; _unitOfWork = unitOfWork; _logger = logger; _readerService = readerService; + _directoryService = directoryService; + _cleanupService = cleanupService; } /// @@ -398,6 +406,14 @@ namespace API.Controllers if (await _unitOfWork.CommitAsync()) { + try + { + await _cleanupService.CleanupBookmarks(); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue cleaning up old bookmarks"); + } return Ok(); } } @@ -455,6 +471,18 @@ namespace API.Controllers var userBookmark = await _unitOfWork.UserRepository.GetBookmarkForPage(bookmarkDto.Page, bookmarkDto.ChapterId, user.Id); + // We need to get the image + var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId); + if (chapter == null) return BadRequest("There was an issue finding image file for reading"); + var path = _cacheService.GetCachedPagePath(chapter, bookmarkDto.Page); + var fileInfo = new FileInfo(path); + + var bookmarkDirectory = + (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; + _directoryService.CopyFileToDirectory(path, Path.Join(bookmarkDirectory, + $"{user.Id}", $"{bookmarkDto.SeriesId}", $"{bookmarkDto.ChapterId}")); + + if (userBookmark == null) { user.Bookmarks ??= new List(); @@ -464,6 +492,8 @@ namespace API.Controllers VolumeId = bookmarkDto.VolumeId, SeriesId = bookmarkDto.SeriesId, ChapterId = bookmarkDto.ChapterId, + FileName = Path.Join($"{user.Id}", $"{bookmarkDto.SeriesId}", $"{bookmarkDto.ChapterId}", fileInfo.Name) + }); _unitOfWork.UserRepository.Update(user); } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index a53521b19..7bee8f1ca 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -9,6 +9,7 @@ using API.Entities.Enums; using API.Extensions; using API.Helpers.Converters; using API.Services; +using AutoMapper; using Kavita.Common; using Kavita.Common.Extensions; using Microsoft.AspNetCore.Authorization; @@ -23,13 +24,18 @@ namespace API.Controllers private readonly IUnitOfWork _unitOfWork; private readonly ITaskScheduler _taskScheduler; private readonly IAccountService _accountService; + private readonly IDirectoryService _directoryService; + private readonly IMapper _mapper; - public SettingsController(ILogger logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, IAccountService accountService) + public SettingsController(ILogger logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, + IAccountService accountService, IDirectoryService directoryService, IMapper mapper) { _logger = logger; _unitOfWork = unitOfWork; _taskScheduler = taskScheduler; _accountService = accountService; + _directoryService = directoryService; + _mapper = mapper; } [AllowAnonymous] @@ -45,11 +51,26 @@ namespace API.Controllers public async Task> GetSettings() { var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + // TODO: Is this needed as it gets updated in the DB on startup settingsDto.Port = Configuration.Port; settingsDto.LoggingLevel = Configuration.LogLevel; return Ok(settingsDto); } + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("reset")] + public async Task> ResetSettings() + { + _logger.LogInformation("{UserName} is resetting Server Settings", User.GetUsername()); + + + // We do not allow CacheDirectory changes, so we will ignore. + // var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync(); + // currentSettings = Seed.DefaultSettings; + + return await UpdateSettings(_mapper.Map(Seed.DefaultSettings)); + } + [Authorize(Policy = "RequireAdminRole")] [HttpPost] public async Task> UpdateSettings(ServerSettingDto updateSettingsDto) @@ -69,6 +90,20 @@ namespace API.Controllers // We do not allow CacheDirectory changes, so we will ignore. var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync(); var updateAuthentication = false; + var updateBookmarks = false; + var originalBookmarkDirectory = _directoryService.BookmarkDirectory; + + var bookmarkDirectory = updateSettingsDto.BookmarksDirectory; + if (!updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks") && + !updateSettingsDto.BookmarksDirectory.EndsWith("bookmarks/")) + { + bookmarkDirectory = _directoryService.FileSystem.Path.Join(updateSettingsDto.BookmarksDirectory, "bookmarks"); + } + + if (string.IsNullOrEmpty(updateSettingsDto.BookmarksDirectory)) + { + bookmarkDirectory = _directoryService.BookmarkDirectory; + } foreach (var setting in currentSettings) { @@ -117,6 +152,22 @@ namespace API.Controllers _unitOfWork.SettingsRepository.Update(setting); } + if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value) + { + // Validate new directory can be used + if (!await _directoryService.CheckWriteAccess(bookmarkDirectory)) + { + return BadRequest("Bookmark Directory does not have correct permissions for Kavita to use"); + } + + originalBookmarkDirectory = setting.Value; + // Normalize the path deliminators. Just to look nice in DB, no functionality + setting.Value = _directoryService.FileSystem.Path.GetFullPath(bookmarkDirectory); + _unitOfWork.SettingsRepository.Update(setting); + updateBookmarks = true; + + } + if (setting.Key == ServerSettingKey.EnableAuthentication && updateSettingsDto.EnableAuthentication + string.Empty != setting.Value) { setting.Value = updateSettingsDto.EnableAuthentication + string.Empty; @@ -159,6 +210,13 @@ namespace API.Controllers _logger.LogInformation("Server authentication changed. Updated all non-admins to default password"); } + + if (updateBookmarks) + { + _directoryService.ExistOrCreate(bookmarkDirectory); + _directoryService.CopyDirectoryToDirectory(originalBookmarkDirectory, bookmarkDirectory); + _directoryService.ClearAndDeleteDirectory(originalBookmarkDirectory); + } } catch (Exception ex) { diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index aace57127..4004c65b1 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -1,4 +1,6 @@ -namespace API.DTOs.Settings +using API.Services; + +namespace API.DTOs.Settings { public class ServerSettingDto { @@ -30,5 +32,10 @@ /// Base Url for the kavita. Requires restart to take effect. /// public string BaseUrl { get; set; } + /// + /// Where Bookmarks are stored. + /// + /// If null or empty string, will default back to default install setting aka + public string BookmarksDirectory { get; set; } } } diff --git a/API/Data/MigrateBookmarks.cs b/API/Data/MigrateBookmarks.cs new file mode 100644 index 000000000..043b3e0a4 --- /dev/null +++ b/API/Data/MigrateBookmarks.cs @@ -0,0 +1,102 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using API.Comparators; +using API.Entities.Enums; +using API.Services; +using Microsoft.Extensions.Logging; + +namespace API.Data; + +/// +/// Responsible to migrate existing bookmarks to files +/// +public static class MigrateBookmarks +{ + private static readonly Version VersionBookmarksChanged = new Version(0, 4, 9, 27); + /// + /// This will migrate existing bookmarks to bookmark folder based + /// + /// Bookmark directory is configurable. This will always use the default bookmark directory. + /// + /// + public static async Task Migrate(IDirectoryService directoryService, IUnitOfWork unitOfWork, + ILogger logger, ICacheService cacheService) + { + var bookmarkDirectory = (await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)) + .Value; + if (string.IsNullOrEmpty(bookmarkDirectory)) + { + bookmarkDirectory = directoryService.BookmarkDirectory; + } + + if (directoryService.Exists(bookmarkDirectory)) return; + + logger.LogInformation("Bookmark migration is needed....This may take some time"); + + var allBookmarks = (await unitOfWork.UserRepository.GetAllBookmarksAsync()).ToList(); + + var uniqueChapterIds = allBookmarks.Select(b => b.ChapterId).Distinct().ToList(); + var uniqueUserIds = allBookmarks.Select(b => b.AppUserId).Distinct().ToList(); + foreach (var userId in uniqueUserIds) + { + foreach (var chapterId in uniqueChapterIds) + { + var chapterBookmarks = allBookmarks.Where(b => b.ChapterId == chapterId).ToList(); + var chapterPages = chapterBookmarks + .Select(b => b.Page).ToList(); + var seriesId = chapterBookmarks + .Select(b => b.SeriesId).First(); + var mangaFiles = await unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); + var chapterExtractPath = directoryService.FileSystem.Path.Join(directoryService.TempDirectory, $"bookmark_c{chapterId}_u{userId}_s{seriesId}"); + + var numericComparer = new NumericComparer(); + if (!mangaFiles.Any()) continue; + + switch (mangaFiles.First().Format) + { + case MangaFormat.Image: + directoryService.ExistOrCreate(chapterExtractPath); + directoryService.CopyFilesToDirectory(mangaFiles.Select(f => f.FilePath), chapterExtractPath); + break; + case MangaFormat.Archive: + case MangaFormat.Pdf: + cacheService.ExtractChapterFiles(chapterExtractPath, mangaFiles.ToList()); + break; + case MangaFormat.Epub: + continue; + default: + continue; + } + + var files = directoryService.GetFilesWithExtension(chapterExtractPath, Parser.Parser.ImageFileExtensions); + // Filter out images that aren't in bookmarks + Array.Sort(files, numericComparer); + foreach (var chapterPage in chapterPages) + { + var file = files.ElementAt(chapterPage); + var bookmark = allBookmarks.FirstOrDefault(b => + b.ChapterId == chapterId && b.SeriesId == seriesId && b.AppUserId == userId && + b.Page == chapterPage); + if (bookmark == null) continue; + + var filename = directoryService.FileSystem.Path.GetFileName(file); + var newLocation = directoryService.FileSystem.Path.Join( + ReaderService.FormatBookmarkFolderPath(String.Empty, userId, seriesId, chapterId), + filename); + bookmark.FileName = newLocation; + directoryService.CopyFileToDirectory(file, + ReaderService.FormatBookmarkFolderPath(bookmarkDirectory, userId, seriesId, chapterId)); + unitOfWork.UserRepository.Update(bookmark); + } + } + // Clear temp after each user to avoid too much space being eaten + directoryService.ClearDirectory(directoryService.TempDirectory); + } + + await unitOfWork.CommitAsync(); + // Run CleanupService as we cache a ton of files + directoryService.ClearDirectory(directoryService.TempDirectory); + + } +} diff --git a/API/Data/Migrations/20211217013734_BookmarkRefactor.Designer.cs b/API/Data/Migrations/20211217013734_BookmarkRefactor.Designer.cs new file mode 100644 index 000000000..5db4111f6 --- /dev/null +++ b/API/Data/Migrations/20211217013734_BookmarkRefactor.Designer.cs @@ -0,0 +1,1317 @@ +// +using System; +using API.Data; +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("20211217013734_BookmarkRefactor")] + partial class BookmarkRefactor + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "6.0.1"); + + 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("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + 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.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.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("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + 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.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.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + 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.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("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .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.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + 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.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ExternalTag") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle", "ExternalTag") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + 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("ChapterPerson", b => + { + b.Property("ChapterMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterMetadatasId", "PeopleId"); + + b.HasIndex("PeopleId"); + + b.ToTable("ChapterPerson"); + }); + + 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("GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + 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("PersonSeriesMetadata", b => + { + b.Property("PeopleId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("PeopleId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("PersonSeriesMetadata"); + }); + + modelBuilder.Entity("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.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .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.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", null) + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .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.Chapter", b => + { + b.HasOne("API.Entities.Genre", null) + .WithMany("Chapters") + .HasForeignKey("GenreId"); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + 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.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + 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.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.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + 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("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("ChapterPerson", b => + { + b.HasOne("API.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChapterMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .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("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("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("PersonSeriesMetadata", b => + { + b.HasOne("API.Entities.Person", null) + .WithMany() + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("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.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Genre", b => + { + b.Navigation("Chapters"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20211217013734_BookmarkRefactor.cs b/API/Data/Migrations/20211217013734_BookmarkRefactor.cs new file mode 100644 index 000000000..7ac831e07 --- /dev/null +++ b/API/Data/Migrations/20211217013734_BookmarkRefactor.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace API.Data.Migrations +{ + public partial class BookmarkRefactor : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FileName", + table: "AppUserBookmark", + type: "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FileName", + table: "AppUserBookmark"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 7666a599a..fe1d6f77c 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -134,6 +134,9 @@ namespace API.Data.Migrations b.Property("ChapterId") .HasColumnType("INTEGER"); + b.Property("FileName") + .HasColumnType("TEXT"); + b.Property("Page") .HasColumnType("INTEGER"); diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 7a520cf6a..2b3bfd478 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -39,12 +39,15 @@ public interface IUserRepository Task> GetBookmarkDtosForVolume(int userId, int volumeId); Task> GetBookmarkDtosForChapter(int userId, int chapterId); Task> GetAllBookmarkDtos(int userId); + Task> GetAllBookmarksAsync(); Task GetBookmarkForPage(int page, int chapterId, int userId); + Task GetBookmarkAsync(int bookmarkId); Task GetUserIdByApiKeyAsync(string apiKey); Task GetUserByUsernameAsync(string username, AppUserIncludes includeFlags = AppUserIncludes.None); Task GetUserByIdAsync(int userId, AppUserIncludes includeFlags = AppUserIncludes.None); Task GetUserIdByUsernameAsync(string username); Task GetUserWithReadingListsByUsernameAsync(string username); + Task> GetAllBookmarksByIds(IList bookmarkIds); } public class UserRepository : IUserRepository @@ -112,6 +115,11 @@ public class UserRepository : IUserRepository return await query.SingleOrDefaultAsync(); } + public async Task> GetAllBookmarksAsync() + { + return await _context.AppUserBookmark.ToListAsync(); + } + public async Task GetBookmarkForPage(int page, int chapterId, int userId) { return await _context.AppUserBookmark @@ -119,6 +127,13 @@ public class UserRepository : IUserRepository .SingleOrDefaultAsync(); } + public async Task GetBookmarkAsync(int bookmarkId) + { + return await _context.AppUserBookmark + .Where(b => b.Id == bookmarkId) + .SingleOrDefaultAsync(); + } + private static IQueryable AddIncludesToQuery(IQueryable query, AppUserIncludes includeFlags) { if (includeFlags.HasFlag(AppUserIncludes.Bookmarks)) @@ -171,6 +186,18 @@ public class UserRepository : IUserRepository .SingleOrDefaultAsync(x => x.UserName == username); } + /// + /// Returns all Bookmarks for a given set of Ids + /// + /// + /// + public async Task> GetAllBookmarksByIds(IList bookmarkIds) + { + return await _context.AppUserBookmark + .Where(b => bookmarkIds.Contains(b.Id)) + .ToListAsync(); + } + public async Task> GetAdminUsersAsync() { return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index e6b758f33..b7590e168 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -8,6 +8,7 @@ using API.Entities; using API.Entities.Enums; using API.Services; using Kavita.Common; +using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -15,6 +16,11 @@ namespace API.Data { public static class Seed { + /// + /// Generated on Startup. Seed.SeedSettings must run before + /// + public static IList DefaultSettings; + public static async Task SeedRoles(RoleManager roleManager) { var roles = typeof(PolicyConstants) @@ -39,7 +45,7 @@ namespace API.Data { await context.Database.EnsureCreatedAsync(); - IList defaultSettings = new List() + DefaultSettings = new List() { new () {Key = ServerSettingKey.CacheDirectory, Value = directoryService.CacheDirectory}, new () {Key = ServerSettingKey.TaskScan, Value = "daily"}, @@ -52,9 +58,11 @@ namespace API.Data new () {Key = ServerSettingKey.EnableAuthentication, Value = "true"}, new () {Key = ServerSettingKey.BaseUrl, Value = "/"}, new () {Key = ServerSettingKey.InstallId, Value = HashUtil.AnonymousToken()}, + new () {Key = ServerSettingKey.InstallVersion, Value = BuildInfo.Version.ToString()}, + new () {Key = ServerSettingKey.BookmarkDirectory, Value = directoryService.BookmarkDirectory}, }; - foreach (var defaultSetting in defaultSettings) + foreach (var defaultSetting in DefaultSettings) { var existing = context.ServerSetting.FirstOrDefault(s => s.Key == defaultSetting.Key); if (existing == null) diff --git a/API/Entities/AppUserBookmark.cs b/API/Entities/AppUserBookmark.cs index cfb9aa29a..7674c419c 100644 --- a/API/Entities/AppUserBookmark.cs +++ b/API/Entities/AppUserBookmark.cs @@ -12,6 +12,10 @@ namespace API.Entities public int VolumeId { get; set; } public int SeriesId { get; set; } public int ChapterId { get; set; } + /// + /// Filename in the Bookmark Directory + /// + public string FileName { get; set; } // Relationships diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index 3f097c675..80484d693 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -58,7 +58,18 @@ namespace API.Entities.Enums /// Represents this installation of Kavita. Is tied to Stat reporting but has no information about user or files. /// [Description("InstallId")] - InstallId = 10 + InstallId = 10, + /// + /// Represents the version the software is running. + /// + /// This will be updated on Startup to the latest release. Provides ability to detect if certain migrations need to be run. + [Description("InstallVersion")] + InstallVersion = 11, + /// + /// Location of where bookmarks are stored + /// + [Description("BookmarkDirectory")] + BookmarkDirectory = 12, } } diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index 86ed6235e..50a839010 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -42,6 +42,9 @@ namespace API.Helpers.Converters case ServerSettingKey.BaseUrl: destination.BaseUrl = row.Value; break; + case ServerSettingKey.BookmarkDirectory: + destination.BookmarksDirectory = row.Value; + break; } } diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 410ec5c47..828bc529f 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -1097,7 +1097,7 @@ namespace API.Parser public static bool HasBlacklistedFolderInPath(string path) { - return path.Contains("__MACOSX") || path.StartsWith("@Recently-Snapshot"); + return path.Contains("__MACOSX") || path.StartsWith("@Recently-Snapshot") || path.StartsWith("._"); } diff --git a/API/Program.cs b/API/Program.cs index b114bbcac..ce123458c 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -6,6 +6,7 @@ using System.Security.Cryptography; using System.Threading.Tasks; using API.Data; using API.Entities; +using API.Entities.Enums; using API.Services; using API.Services.Tasks; using Kavita.Common; @@ -80,6 +81,7 @@ namespace API requiresCoverImageMigration = false; } + // Apply all migrations on startup // If we have pending migrations, make a backup first var pendingMigrations = await context.Database.GetPendingMigrationsAsync(); diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index 40fa61285..36b5b3124 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -21,6 +21,10 @@ namespace API.Services string TempDirectory { get; } string ConfigDirectory { get; } /// + /// Original BookmarkDirectory. Only used for resetting directory. Use for actual path. + /// + string BookmarkDirectory { get; } + /// /// Lists out top-level folders for a given directory. Filters out System and Hidden folders. /// /// Absolute path of directory to scan. @@ -50,7 +54,7 @@ namespace API.Services void DeleteFiles(IEnumerable files); void RemoveNonImages(string directoryName); void Flatten(string directoryName); - + Task CheckWriteAccess(string directoryName); } public class DirectoryService : IDirectoryService { @@ -60,6 +64,7 @@ namespace API.Services public string LogDirectory { get; } public string TempDirectory { get; } public string ConfigDirectory { get; } + public string BookmarkDirectory { get; } private readonly ILogger _logger; private static readonly Regex ExcludeDirectories = new Regex( @@ -76,6 +81,7 @@ namespace API.Services LogDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "logs"); TempDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "temp"); ConfigDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config"); + BookmarkDirectory = FileSystem.Path.Join(FileSystem.Directory.GetCurrentDirectory(), "config", "bookmarks"); } /// @@ -268,7 +274,7 @@ namespace API.Services /// public bool IsDirectoryEmpty(string path) { - return Directory.EnumerateFileSystemEntries(path).Any(); + return FileSystem.Directory.Exists(path) && !FileSystem.Directory.EnumerateFileSystemEntries(path).Any(); } public string[] GetFilesWithExtension(string path, string searchPatternExpression = "") @@ -682,6 +688,30 @@ namespace API.Services FlattenDirectory(directory, directory, ref index); } + /// + /// Checks whether a directory has write permissions + /// + /// Fully qualified path + /// + public async Task CheckWriteAccess(string directoryName) + { + try + { + ExistOrCreate(directoryName); + await FileSystem.File.WriteAllTextAsync( + FileSystem.Path.Join(directoryName, "test.txt"), + string.Empty); + } + catch (Exception ex) + { + ClearAndDeleteDirectory(directoryName); + return false; + } + + ClearAndDeleteDirectory(directoryName); + return true; + } + private void FlattenDirectory(IDirectoryInfo root, IDirectoryInfo directory, ref int directoryIndex) { diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index c39ac3239..e0d9d7d68 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; @@ -19,19 +20,29 @@ public interface IReaderService Task CapPageToChapter(int chapterId, int page); Task GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); Task GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId); + //Task BookmarkFile(); } public class ReaderService : IReaderService { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; + private readonly IDirectoryService _directoryService; + private readonly ICacheService _cacheService; private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer(); private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); - public ReaderService(IUnitOfWork unitOfWork, ILogger logger) + public ReaderService(IUnitOfWork unitOfWork, ILogger logger, IDirectoryService directoryService, ICacheService cacheService) { _unitOfWork = unitOfWork; _logger = logger; + _directoryService = directoryService; + _cacheService = cacheService; + } + + public static string FormatBookmarkFolderPath(string baseDirectory, int userId, int seriesId, int chapterId) + { + return Path.Join(baseDirectory, $"{userId}", $"{seriesId}", $"{chapterId}"); } /// @@ -299,6 +310,17 @@ public class ReaderService : IReaderService return -1; } + // public async Task BookmarkFile() + // { + // var chapter = await _cacheService.Ensure(bookmarkDto.ChapterId); + // if (chapter == null) return string.Empty; + // var path = _cacheService.GetCachedPagePath(chapter, bookmarkDto.Page); + // var fileInfo = new FileInfo(path); + // + // return _directoryService.CopyFileToDirectory(path, Path.Join(_directoryService.BookmarkDirectory, + // $"{user.Id}", $"{bookmarkDto.SeriesId}")); + // } + private static int GetNextChapterId(IEnumerable chapters, string currentChapterNumber) { var next = false; diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index 6d8b705a9..293dddd5e 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Linq; using System.Threading.Tasks; using API.Data; @@ -19,6 +20,7 @@ namespace API.Services.Tasks Task DeleteChapterCoverImages(); Task DeleteTagCoverImages(); Task CleanupBackups(); + Task CleanupBookmarks(); } /// /// Cleans up after operations on reoccurring basis @@ -63,6 +65,9 @@ namespace API.Services.Tasks await DeleteChapterCoverImages(); await SendProgress(0.7F); await DeleteTagCoverImages(); + await SendProgress(0.8F); + _logger.LogInformation("Cleaning old bookmarks"); + await CleanupBookmarks(); await SendProgress(1F); _logger.LogInformation("Cleanup finished"); } @@ -163,5 +168,34 @@ namespace API.Services.Tasks } _logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now); } + + /// + /// Removes all files in the BookmarkDirectory that don't currently have bookmarks in the Database + /// + public async Task CleanupBookmarks() + { + // Search all files in bookmarks/ + // except bookmark files and delete those + var bookmarkDirectory = + (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value; + var allBookmarkFiles = _directoryService.GetFiles(bookmarkDirectory, searchOption: SearchOption.AllDirectories); + var bookmarks = (await _unitOfWork.UserRepository.GetAllBookmarksAsync()) + .Select(b => _directoryService.FileSystem.Path.Join(bookmarkDirectory, + b.FileName)); + + var filesToDelete = allBookmarkFiles.Except(bookmarks); + + _directoryService.DeleteFiles(filesToDelete); + + // Clear all empty directories + foreach (var directory in _directoryService.FileSystem.Directory.GetDirectories(bookmarkDirectory)) + { + if (_directoryService.FileSystem.Directory.GetFiles(directory).Length == 0 && + _directoryService.FileSystem.Directory.GetDirectories(directory).Length == 0) + { + _directoryService.FileSystem.Directory.Delete(directory, false); + } + } + } } } diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index c4f4659e8..975179d86 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -84,6 +84,8 @@ namespace API.Services.Tasks.Scanner { ParserInfo info = null; + // TODO: Emit event with what is being processed. It can look like Kavita isn't doing anything during file scan + if (Parser.Parser.IsEpub(path)) { info = _readingItemService.Parse(path, rootPath, type); @@ -114,8 +116,6 @@ namespace API.Services.Tasks.Scanner info.ComicInfo = GetComicInfo(path); if (info.ComicInfo != null) { - var sw = Stopwatch.StartNew(); - if (!string.IsNullOrEmpty(info.ComicInfo.Volume)) { info.Volumes = info.ComicInfo.Volume; diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 4d3679e97..624ed5462 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -222,9 +222,12 @@ public class ScannerService : IScannerService } // For Docker instances check if any of the folder roots are not available (ie disconnected volumes, etc) and fail if any of them are - if (library.Folders.Any(f => !_directoryService.IsDirectoryEmpty(f.Path))) + if (library.Folders.Any(f => _directoryService.IsDirectoryEmpty(f.Path))) { - _logger.LogError("Some of the root folders for the library are empty. Either your mount has been disconnected or you are trying to delete all series in the library. Scan will be aborted. Check that your mount is connected or change the library's root folder and rescan."); + _logger.LogError("Some of the root folders for the library are empty. " + + "Either your mount has been disconnected or you are trying to delete all series in the library. " + + "Scan will be aborted. " + + "Check that your mount is connected or change the library's root folder and rescan"); return; } diff --git a/API/Startup.cs b/API/Startup.cs index 20218d866..acb6b615f 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -4,6 +4,8 @@ using System.IO.Compression; using System.Linq; using System.Net; using System.Net.Sockets; +using System.Threading.Tasks; +using API.Data; using API.Extensions; using API.Middleware; using API.Services; @@ -24,6 +26,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.OpenApi.Models; +using TaskScheduler = API.Services.TaskScheduler; namespace API { @@ -126,8 +129,15 @@ namespace API // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJobs, IWebHostEnvironment env, - IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider) + IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider, ICacheService cacheService, + IDirectoryService directoryService, IUnitOfWork unitOfWork) { + + // Apply Migrations + Task.Run(async () => await MigrateBookmarks.Migrate(directoryService, unitOfWork, + serviceProvider.GetRequiredService>(), cacheService)).GetAwaiter().GetResult(); + + app.UseMiddleware(); if (env.IsDevelopment()) diff --git a/API/config/appsettings.Development.json b/API/config/appsettings.Development.json index 21f688931..ac1707592 100644 --- a/API/config/appsettings.Development.json +++ b/API/config/appsettings.Development.json @@ -5,7 +5,7 @@ "TokenKey": "super secret unguessable key", "Logging": { "LogLevel": { - "Default": "Debug", + "Default": "Information", "Microsoft": "Information", "Microsoft.Hosting.Lifetime": "Error", "Hangfire": "Information", diff --git a/UI/Web/src/app/_models/page-bookmark.ts b/UI/Web/src/app/_models/page-bookmark.ts index 6d46bfe4d..e47ef0a06 100644 --- a/UI/Web/src/app/_models/page-bookmark.ts +++ b/UI/Web/src/app/_models/page-bookmark.ts @@ -4,4 +4,5 @@ export interface PageBookmark { seriesId: number; volumeId: number; chapterId: number; + fileName: string; } \ No newline at end of file diff --git a/UI/Web/src/app/_services/image.service.ts b/UI/Web/src/app/_services/image.service.ts index 2141d3e4d..4cb21c299 100644 --- a/UI/Web/src/app/_services/image.service.ts +++ b/UI/Web/src/app/_services/image.service.ts @@ -1,18 +1,24 @@ -import { Injectable } from '@angular/core'; +import { Injectable, OnDestroy } from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; +import { AccountService } from './account.service'; import { NavService } from './nav.service'; @Injectable({ providedIn: 'root' }) -export class ImageService { +export class ImageService implements OnDestroy { baseUrl = environment.apiUrl; + apiKey: string = ''; public placeholderImage = 'assets/images/image-placeholder-min.png'; public errorImage = 'assets/images/error-placeholder2-min.png'; public resetCoverImage = 'assets/images/image-reset-cover-min.png'; - constructor(private navSerivce: NavService) { + private onDestroy: Subject = new Subject(); + + constructor(private navSerivce: NavService, private accountService: AccountService) { this.navSerivce.darkMode$.subscribe(res => { if (res) { this.placeholderImage = 'assets/images/image-placeholder.dark-min.png'; @@ -22,6 +28,17 @@ export class ImageService { this.errorImage = 'assets/images/error-placeholder2-min.png'; } }); + + this.accountService.currentUser$.pipe(takeUntil(this.onDestroy)).subscribe(user => { + if (user) { + this.apiKey = user.apiKey; + } + }); + } + + ngOnDestroy(): void { + this.onDestroy.next(); + this.onDestroy.complete(); } getVolumeCoverImage(volumeId: number) { @@ -41,7 +58,7 @@ export class ImageService { } getBookmarkedImage(chapterId: number, pageNum: number) { - return this.baseUrl + 'image/chapter-cover?chapterId=' + chapterId + '&pageNum=' + pageNum; + return this.baseUrl + 'image/bookmark?chapterId=' + chapterId + '&pageNum=' + pageNum + '&apiKey=' + encodeURIComponent(this.apiKey); } updateErroredImage(event: any) { diff --git a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html index 6f6d02630..60debc54d 100644 --- a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html +++ b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.html @@ -36,6 +36,7 @@ Back + @@ -50,6 +51,6 @@ diff --git a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts index 85fa530bb..2885c40de 100644 --- a/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts +++ b/UI/Web/src/app/admin/_modals/directory-picker/directory-picker.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, Input, OnInit } from '@angular/core'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { Stack } from 'src/app/shared/data-structures/stack'; import { LibraryService } from '../../../_services/library.service'; @@ -17,6 +17,12 @@ export interface DirectoryPickerResult { }) export class DirectoryPickerComponent implements OnInit { + @Input() startingFolder: string = ''; + /** + * Url to give more information about selecting directories. Passing nothing will suppress. + */ + @Input() helpUrl: string = 'https://wiki.kavitareader.com/en/guides/adding-a-library'; + currentRoot = ''; folders: string[] = []; routeStack: Stack = new Stack(); @@ -27,7 +33,22 @@ export class DirectoryPickerComponent implements OnInit { } ngOnInit(): void { - this.loadChildren(this.currentRoot); + if (this.startingFolder && this.startingFolder.length > 0) { + let folders = this.startingFolder.split('/'); + let folders2 = this.startingFolder.split('\\'); + if (folders.length === 1 && folders2.length > 1) { + folders = folders2; + } + if (!folders[0].endsWith('/')) { + folders[0] = folders[0] + '/'; + } + folders.forEach(folder => this.routeStack.push(folder)); + + const fullPath = this.routeStack.items.join('/'); + this.loadChildren(fullPath); + } else { + this.loadChildren(this.currentRoot); + } } filterFolder = (folder: string) => { @@ -38,7 +59,7 @@ export class DirectoryPickerComponent implements OnInit { this.currentRoot = folderName; this.routeStack.push(folderName); const fullPath = this.routeStack.items.join('/'); - this.loadChildren(fullPath); + this.loadChildren(fullPath); } goBack() { @@ -86,7 +107,7 @@ export class DirectoryPickerComponent implements OnInit { if (lastPath && lastPath != path) { let replaced = path.replace(lastPath, ''); if (replaced.startsWith('/') || replaced.startsWith('\\')) { - replaced = replaced.substr(1, replaced.length); + replaced = replaced.substring(1, replaced.length); } return replaced; } @@ -95,14 +116,11 @@ export class DirectoryPickerComponent implements OnInit { } navigateTo(index: number) { - const numberOfPops = this.routeStack.items.length - index; - if (this.routeStack.items.length - numberOfPops > this.routeStack.items.length) { - this.routeStack.items = []; - } - for (let i = 0; i < numberOfPops; i++) { + while(this.routeStack.items.length - 1 > index) { this.routeStack.pop(); } - - this.loadChildren(this.routeStack.peek() || ''); + + const fullPath = this.routeStack.items.join('/'); + this.loadChildren(fullPath); } } diff --git a/UI/Web/src/app/admin/_models/server-settings.ts b/UI/Web/src/app/admin/_models/server-settings.ts index fbcb2a0f0..1f5b398e9 100644 --- a/UI/Web/src/app/admin/_models/server-settings.ts +++ b/UI/Web/src/app/admin/_models/server-settings.ts @@ -8,4 +8,5 @@ export interface ServerSettings { enableOpds: boolean; enableAuthentication: boolean; baseUrl: string; + bookmarksDirectory: string; } diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html index 727d1069f..ecda0b4ac 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html @@ -8,6 +8,20 @@ +
+   + Location where bookmarks will be stored. Bookmarks are source files and can be large. Choose a location with adequate storage. Directory is managed, other files within directory will be deleted. + +
+ +
+ +
+
+
+ -
-   - Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect. - Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect. - -
- -
-   - Use debug to help identify issues. Debug can eat up a lot of disk space. Requires restart to take effect. - Port the server listens on. Requires restart to take effect. - +
+
+   + Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect. + Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect. + +
+ +
+   + Use debug to help identify issues. Debug can eat up a lot of disk space. Requires restart to take effect. + Port the server listens on. Requires restart to take effect. + +
@@ -78,6 +94,7 @@
+
diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts index 94509776a..eadfe5b71 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts @@ -1,9 +1,11 @@ import { Component, OnInit } from '@angular/core'; import { FormGroup, FormControl, Validators } from '@angular/forms'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; import { take } from 'rxjs/operators'; import { ConfirmService } from 'src/app/shared/confirm.service'; import { SettingsService } from '../settings.service'; +import { DirectoryPickerComponent, DirectoryPickerResult } from '../_modals/directory-picker/directory-picker.component'; import { ServerSettings } from '../_models/server-settings'; @Component({ @@ -18,7 +20,8 @@ export class ManageSettingsComponent implements OnInit { taskFrequencies: Array = []; logLevels: Array = []; - constructor(private settingsService: SettingsService, private toastr: ToastrService, private confirmService: ConfirmService) { } + constructor(private settingsService: SettingsService, private toastr: ToastrService, private confirmService: ConfirmService, + private modalService: NgbModal) { } ngOnInit(): void { this.settingsService.getTaskFrequencies().pipe(take(1)).subscribe(frequencies => { @@ -30,6 +33,7 @@ export class ManageSettingsComponent implements OnInit { this.settingsService.getServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => { this.serverSettings = settings; this.settingsForm.addControl('cacheDirectory', new FormControl(this.serverSettings.cacheDirectory, [Validators.required])); + this.settingsForm.addControl('bookmarksDirectory', new FormControl(this.serverSettings.bookmarksDirectory, [Validators.required])); this.settingsForm.addControl('taskScan', new FormControl(this.serverSettings.taskScan, [Validators.required])); this.settingsForm.addControl('taskBackup', new FormControl(this.serverSettings.taskBackup, [Validators.required])); this.settingsForm.addControl('port', new FormControl(this.serverSettings.port, [Validators.required])); @@ -43,6 +47,7 @@ export class ManageSettingsComponent implements OnInit { resetForm() { this.settingsForm.get('cacheDirectory')?.setValue(this.serverSettings.cacheDirectory); + this.settingsForm.get('bookmarksDirectory')?.setValue(this.serverSettings.bookmarksDirectory); this.settingsForm.get('scanTask')?.setValue(this.serverSettings.taskScan); this.settingsForm.get('taskBackup')?.setValue(this.serverSettings.taskBackup); this.settingsForm.get('port')?.setValue(this.serverSettings.port); @@ -77,4 +82,26 @@ export class ManageSettingsComponent implements OnInit { }); } + resetToDefaults() { + this.settingsService.resetServerSettings().pipe(take(1)).subscribe(async (settings: ServerSettings) => { + this.serverSettings = settings; + this.resetForm(); + this.toastr.success('Server settings updated'); + }, (err: any) => { + console.error('error: ', err); + }); + } + + openDirectoryChooser(existingDirectory: string, formControl: string) { + const modalRef = this.modalService.open(DirectoryPickerComponent, { scrollable: true, size: 'lg' }); + modalRef.componentInstance.startingFolder = existingDirectory || ''; + modalRef.componentInstance.helpUrl = ''; + modalRef.closed.subscribe((closeResult: DirectoryPickerResult) => { + if (closeResult.success) { + this.settingsForm.get(formControl)?.setValue(closeResult.folderPath); + this.settingsForm.markAsTouched(); + } + }); + } + } diff --git a/UI/Web/src/app/admin/settings.service.ts b/UI/Web/src/app/admin/settings.service.ts index 9f0de0b3f..646fde087 100644 --- a/UI/Web/src/app/admin/settings.service.ts +++ b/UI/Web/src/app/admin/settings.service.ts @@ -21,6 +21,10 @@ export class SettingsService { return this.http.post(this.baseUrl + 'settings', model); } + resetServerSettings() { + return this.http.post(this.baseUrl + 'settings/reset', {}); + } + getTaskFrequencies() { return this.http.get(this.baseUrl + 'settings/task-frequencies'); } diff --git a/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.html b/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.html index 2318b61e3..2dd4b93e1 100644 --- a/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.html +++ b/UI/Web/src/app/cards/_modals/bookmarks-modal/bookmarks-modal.component.html @@ -5,15 +5,16 @@
+ + + \ No newline at end of file diff --git a/UI/Web/src/app/cards/bookmark/bookmark.component.scss b/UI/Web/src/app/cards/bookmark/bookmark.component.scss new file mode 100644 index 000000000..b64b52e8d --- /dev/null +++ b/UI/Web/src/app/cards/bookmark/bookmark.component.scss @@ -0,0 +1,25 @@ +.card-body { + padding: 5px; +} + +.card { + margin-left: 5px; + margin-right: 5px; +} + +.header-row { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.title-overflow { + font-size: 13px; + width: 130px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + display: block; + margin-top: 2px; + margin-bottom: 0px; +} \ No newline at end of file diff --git a/UI/Web/src/app/cards/bookmark/bookmark.component.ts b/UI/Web/src/app/cards/bookmark/bookmark.component.ts new file mode 100644 index 000000000..437eb2227 --- /dev/null +++ b/UI/Web/src/app/cards/bookmark/bookmark.component.ts @@ -0,0 +1,43 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { Series } from 'src/app/_models/series'; +import { ImageService } from 'src/app/_services/image.service'; +import { ReaderService } from 'src/app/_services/reader.service'; +import { SeriesService } from 'src/app/_services/series.service'; +import { PageBookmark } from '../../_models/page-bookmark'; + +@Component({ + selector: 'app-bookmark', + templateUrl: './bookmark.component.html', + styleUrls: ['./bookmark.component.scss'] +}) +export class BookmarkComponent implements OnInit { + + @Input() bookmark: PageBookmark | undefined; + @Output() bookmarkRemoved: EventEmitter = new EventEmitter(); + series: Series | undefined; + + isClearing: boolean = false; + isDownloading: boolean = false; + + constructor(public imageService: ImageService, private seriesService: SeriesService, private readerService: ReaderService) { } + + ngOnInit(): void { + if (this.bookmark) { + this.seriesService.getSeries(this.bookmark.seriesId).subscribe(series => { + this.series = series; + }); + } + } + + handleClick(event: any) { + + } + + removeBookmark() { + if (this.bookmark === undefined) return; + this.readerService.unbookmark(this.bookmark.seriesId, this.bookmark.volumeId, this.bookmark.chapterId, this.bookmark.page).subscribe(res => { + this.bookmarkRemoved.emit(this.bookmark); + this.bookmark = undefined; + }); + } +} diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index 3bcc64641..c096aac5b 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -20,13 +20,13 @@
- +
- + (promoted) diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index f65efaf85..6788881a3 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -8,6 +8,7 @@ import { UtilityService } from 'src/app/shared/_services/utility.service'; import { Chapter } from 'src/app/_models/chapter'; import { CollectionTag } from 'src/app/_models/collection-tag'; import { MangaFormat } from 'src/app/_models/manga-format'; +import { PageBookmark } from 'src/app/_models/page-bookmark'; import { Series } from 'src/app/_models/series'; import { Volume } from 'src/app/_models/volume'; import { Action, ActionItem } from 'src/app/_services/action-factory.service'; @@ -49,7 +50,7 @@ export class CardItemComponent implements OnInit, OnDestroy { /** * This is the entity we are representing. It will be returned if an action is executed. */ - @Input() entity!: Series | Volume | Chapter | CollectionTag; + @Input() entity!: Series | Volume | Chapter | CollectionTag | PageBookmark; /** * If the entity is selected or not. */ diff --git a/UI/Web/src/app/cards/cards.module.ts b/UI/Web/src/app/cards/cards.module.ts index bd053352f..8b21fe1ac 100644 --- a/UI/Web/src/app/cards/cards.module.ts +++ b/UI/Web/src/app/cards/cards.module.ts @@ -8,7 +8,7 @@ import { EditCollectionTagsComponent } from './_modals/edit-collection-tags/edit import { ChangeCoverImageModalComponent } from './_modals/change-cover-image/change-cover-image-modal.component'; import { BookmarksModalComponent } from './_modals/bookmarks-modal/bookmarks-modal.component'; import { LazyLoadImageModule } from 'ng-lazyload-image'; -import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbAccordionModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbTooltipModule, NgbCollapseModule, NgbPaginationModule, NgbDropdownModule, NgbProgressbarModule, NgbNavModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap'; import { CardActionablesComponent } from './card-item/card-actionables/card-actionables.component'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { NgxFileDropModule } from 'ngx-file-drop'; @@ -23,6 +23,7 @@ import { BulkAddToCollectionComponent } from './_modals/bulk-add-to-collection/b import { PipeModule } from '../pipe/pipe.module'; import { ChapterMetadataDetailComponent } from './chapter-metadata-detail/chapter-metadata-detail.component'; import { FileInfoComponent } from './file-info/file-info.component'; +import { BookmarkComponent } from './bookmark/bookmark.component'; @@ -42,7 +43,8 @@ import { FileInfoComponent } from './file-info/file-info.component'; BulkOperationsComponent, BulkAddToCollectionComponent, ChapterMetadataDetailComponent, - FileInfoComponent + FileInfoComponent, + BookmarkComponent, ], imports: [ CommonModule, diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.scss b/UI/Web/src/app/manga-reader/manga-reader.component.scss index 8357a363b..ff14d355d 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.scss +++ b/UI/Web/src/app/manga-reader/manga-reader.component.scss @@ -56,7 +56,7 @@ canvas { .overlay { background-color: rgba(0,0,0,0.5); - backdrop-filter: blur(10px); + backdrop-filter: blur(10px); // BUG: This doesn't work on Firefox color: white; }