diff --git a/.github/workflows/sonar-scan.yml b/.github/workflows/sonar-scan.yml
index 52585bed1..ec7837fdc 100644
--- a/.github/workflows/sonar-scan.yml
+++ b/.github/workflows/sonar-scan.yml
@@ -183,6 +183,12 @@ jobs:
with:
proj-path: Kavita.Common/Kavita.Common.csproj
+ - name: Parse Version
+ run: |
+ version='${{steps.get-version.outputs.assembly-version}}'
+ echo "::set-output name=VERSION::$version"
+ id: parse-version
+
- name: Echo csproj version
run: echo "${{steps.get-version.outputs.assembly-version}}"
@@ -213,7 +219,7 @@ jobs:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: true
- tags: kizaing/kavita:nightly
+ tags: kizaing/kavita:nightly, kizaing/kavita:nightly-${{ steps.parse-version.outputs.VERSION }}
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
@@ -224,7 +230,7 @@ jobs:
severity: info
description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }}
details: '${{ steps.parse-body.outputs.BODY }}'
- text: A new nightly build has been released for docker.
+ text: <@&939225459156217917> <@&939225350775406643> A new nightly build has been released for docker.
webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}
stable:
@@ -338,5 +344,5 @@ jobs:
severity: info
description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }}
details: '${{ steps.parse-body.outputs.BODY }}'
- text: A new stable build has been released.
+ text: <@&939225192553644133> A new stable build has been released.
webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}
diff --git a/.gitignore b/.gitignore
index 8bc302ff8..c8d68977f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -510,11 +510,13 @@ UI/Web/dist/
/API/config/backups/
/API/config/cache/
/API/config/temp/
+/API/config/themes/
/API/config/stats/
/API/config/bookmarks/
/API/config/kavita.db
/API/config/kavita.db-shm
/API/config/kavita.db-wal
+/API/config/kavita.db-journal
/API/config/Hangfire.db
/API/config/Hangfire-log.db
API/config/covers/
diff --git a/API.Benchmark/API.Benchmark.csproj b/API.Benchmark/API.Benchmark.csproj
index cbf7d76f9..bd78c1a8d 100644
--- a/API.Benchmark/API.Benchmark.csproj
+++ b/API.Benchmark/API.Benchmark.csproj
@@ -12,7 +12,7 @@
-
+
diff --git a/API.Benchmark/ParseScannedFilesBenchmarks.cs b/API.Benchmark/ParseScannedFilesBenchmarks.cs
index a180d566f..7c244a5d4 100644
--- a/API.Benchmark/ParseScannedFilesBenchmarks.cs
+++ b/API.Benchmark/ParseScannedFilesBenchmarks.cs
@@ -4,6 +4,7 @@ using API.Entities.Enums;
using API.Parser;
using API.Services;
using API.Services.Tasks.Scanner;
+using API.SignalR;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
using Microsoft.Extensions.Logging;
@@ -28,7 +29,8 @@ namespace API.Benchmark
_parseScannedFiles = new ParseScannedFiles(
Substitute.For(),
directoryService,
- new ReadingItemService(_archiveService, new BookService(_bookLogger, directoryService, new ImageService(Substitute.For>(), directoryService)), Substitute.For(), directoryService));
+ new ReadingItemService(_archiveService, new BookService(_bookLogger, directoryService, new ImageService(Substitute.For>(), directoryService)), Substitute.For(), directoryService),
+ Substitute.For());
}
// [Benchmark]
@@ -59,8 +61,7 @@ namespace API.Benchmark
Title = "A Town Where You Live",
Volumes = "1"
};
- _parseScannedFiles.ScanLibrariesForSeries(LibraryType.Manga, new [] {libraryPath},
- out _, out _);
+ _parseScannedFiles.ScanLibrariesForSeries(LibraryType.Manga, new [] {libraryPath}, "Manga");
_parseScannedFiles.MergeName(p1);
}
}
diff --git a/API.Benchmark/ParserBenchmarks.cs b/API.Benchmark/ParserBenchmarks.cs
index 98e83eb00..63adc6985 100644
--- a/API.Benchmark/ParserBenchmarks.cs
+++ b/API.Benchmark/ParserBenchmarks.cs
@@ -54,7 +54,7 @@ namespace API.Benchmark
{
foreach (var name in _names)
{
- if ((name + ".epub").ToLower() == ".epub")
+ if ((name).ToLower() == ".epub")
{
/* No Operation */
}
@@ -67,7 +67,7 @@ namespace API.Benchmark
foreach (var name in _names)
{
- if (IsEpub.IsMatch((name + ".epub")))
+ if (Path.GetExtension(name).Equals(".epub", StringComparison.InvariantCultureIgnoreCase))
{
/* No Operation */
}
diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj
index 4f268a38a..782385ffc 100644
--- a/API.Tests/API.Tests.csproj
+++ b/API.Tests/API.Tests.csproj
@@ -7,16 +7,16 @@
-
-
-
-
+
+
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
diff --git a/API.Tests/Comparers/SortComparerZeroLastTests.cs b/API.Tests/Comparers/SortComparerZeroLastTests.cs
new file mode 100644
index 000000000..37699d110
--- /dev/null
+++ b/API.Tests/Comparers/SortComparerZeroLastTests.cs
@@ -0,0 +1,17 @@
+using System.Linq;
+using API.Comparators;
+using Xunit;
+
+namespace API.Tests.Comparers;
+
+public class SortComparerZeroLastTests
+{
+ [Theory]
+ [InlineData(new[] {0, 1, 2,}, new[] {1, 2, 0})]
+ [InlineData(new[] {3, 1, 2}, new[] {1, 2, 3})]
+ [InlineData(new[] {0, 0, 1}, new[] {1, 0, 0})]
+ public void SortComparerZeroLastTest(int[] input, int[] expected)
+ {
+ Assert.Equal(expected, input.OrderBy(f => f, new SortComparerZeroLast()).ToArray());
+ }
+}
diff --git a/API.Tests/Helpers/EntityFactory.cs b/API.Tests/Helpers/EntityFactory.cs
index 25b807c32..e98cd5730 100644
--- a/API.Tests/Helpers/EntityFactory.cs
+++ b/API.Tests/Helpers/EntityFactory.cs
@@ -1,5 +1,5 @@
-using System;
-using System.Collections.Generic;
+using System.Collections.Generic;
+using System.Linq;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
@@ -26,12 +26,14 @@ namespace API.Tests.Helpers
public static Volume CreateVolume(string volumeNumber, List chapters = null)
{
+ var chaps = chapters ?? new List();
+ var pages = chaps.Count > 0 ? chaps.Max(c => c.Pages) : 0;
return new Volume()
{
Name = volumeNumber,
- Number = int.Parse(volumeNumber),
- Pages = 0,
- Chapters = chapters ?? new List()
+ Number = (int) API.Parser.Parser.MinimumNumberFromRange(volumeNumber),
+ Pages = pages,
+ Chapters = chaps
};
}
diff --git a/API.Tests/Parser/BookParserTests.cs b/API.Tests/Parser/BookParserTests.cs
index 7f6975fe5..cb91fc947 100644
--- a/API.Tests/Parser/BookParserTests.cs
+++ b/API.Tests/Parser/BookParserTests.cs
@@ -6,6 +6,7 @@ namespace API.Tests.Parser
{
[Theory]
[InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown]", "Gifting The Wonderful World With Blessings!")]
+ [InlineData("BBC Focus 00 The Science of Happiness 2nd Edition (2018)", "BBC Focus 00 The Science of Happiness 2nd Edition")]
public void ParseSeriesTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename));
diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs
index 171e582cb..34c41015c 100644
--- a/API.Tests/Parser/MangaParserTests.cs
+++ b/API.Tests/Parser/MangaParserTests.cs
@@ -159,7 +159,6 @@ namespace API.Tests.Parser
[InlineData("The 100 Girlfriends Who Really, Really, Really, Really, Really Love You - Vol. 03 Ch. 023.5 - Volume 3 Extras.cbz", "The 100 Girlfriends Who Really, Really, Really, Really, Really Love You")]
[InlineData("Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo Chapter 1-10", "Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo")]
[InlineData("The Duke of Death and His Black Maid - Ch. 177 - The Ball (3).cbz", "The Duke of Death and His Black Maid")]
- [InlineData("A Compendium of Ghosts - 031 - The Third Story_ Part 12 (Digital) (Cobalt001)", "A Compendium of Ghosts")]
[InlineData("The Duke of Death and His Black Maid - Vol. 04 Ch. 054.5 - V4 Omake", "The Duke of Death and His Black Maid")]
[InlineData("Vol. 04 Ch. 054.5", "")]
[InlineData("Great_Teacher_Onizuka_v16[TheSpectrum]", "Great Teacher Onizuka")]
@@ -168,6 +167,8 @@ namespace API.Tests.Parser
[InlineData("Kaiju No. 8 036 (2021) (Digital)", "Kaiju No. 8")]
[InlineData("Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz", "Seraph of the End - Vampire Reign")]
[InlineData("Love Hina - Volume 01 [Scans].pdf", "Love Hina")]
+ [InlineData("It's Witching Time! 001 (Digital) (Anonymous1234)", "It's Witching Time!")]
+ [InlineData("Zettai Karen Children v02 c003 - The Invisible Guardian (2) [JS Scans]", "Zettai Karen Children")]
public void ParseSeriesTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename));
diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parser/ParserTest.cs
index 02cd81aa4..e57f56928 100644
--- a/API.Tests/Parser/ParserTest.cs
+++ b/API.Tests/Parser/ParserTest.cs
@@ -15,6 +15,16 @@ namespace API.Tests.Parser
Assert.Equal(expected, CleanAuthor(input));
}
+ [Theory]
+ [InlineData("", "")]
+ [InlineData("DEAD Tube Prologue", "DEAD Tube Prologue")]
+ [InlineData("DEAD Tube Prologue SP01", "DEAD Tube Prologue")]
+ [InlineData("DEAD_Tube_Prologue SP01", "DEAD Tube Prologue")]
+ public void CleanSpecialTitleTest(string input, string expected)
+ {
+ Assert.Equal(expected, CleanSpecialTitle(input));
+ }
+
[Theory]
[InlineData("Beastars - SP01", true)]
[InlineData("Beastars SP01", true)]
@@ -153,6 +163,7 @@ namespace API.Tests.Parser
[InlineData("Citrus+", "citrus+")]
[InlineData("Again!!!!", "again")]
[InlineData("카비타", "카비타")]
+ [InlineData("06", "06")]
[InlineData("", "")]
public void NormalizeTest(string input, string expected)
{
@@ -198,6 +209,9 @@ namespace API.Tests.Parser
[InlineData("MACOSX/Love Hina/", false)]
[InlineData("._Love Hina/Love Hina/", true)]
[InlineData("@Recently-Snapshot/Love Hina/", true)]
+ [InlineData("@recycle/Love Hina/", true)]
+ [InlineData("@recycle/Love Hina/", true)]
+ [InlineData("E:/Test/__MACOSX/Love Hina/", true)]
public void HasBlacklistedFolderInPathTest(string inputPath, bool expected)
{
Assert.Equal(expected, HasBlacklistedFolderInPath(inputPath));
diff --git a/API.Tests/Services/ArchiveServiceTests.cs b/API.Tests/Services/ArchiveServiceTests.cs
index 7de8bb2bf..de27464c9 100644
--- a/API.Tests/Services/ArchiveServiceTests.cs
+++ b/API.Tests/Services/ArchiveServiceTests.cs
@@ -152,16 +152,14 @@ namespace API.Tests.Services
}
-
- // TODO: This is broken on GA due to DirectoryService.CoverImageDirectory
- //[Theory]
- [InlineData("v10.cbz", "v10.expected.jpg")]
- [InlineData("v10 - with folder.cbz", "v10 - with folder.expected.jpg")]
- [InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.jpg")]
- [InlineData("macos_native.zip", "macos_native.jpg")]
- [InlineData("v10 - duplicate covers.cbz", "v10 - duplicate covers.expected.jpg")]
- [InlineData("sorting.zip", "sorting.expected.jpg")]
- [InlineData("test.zip", "test.expected.jpg")] // https://github.com/kleisauke/net-vips/issues/155
+ [Theory]
+ [InlineData("v10.cbz", "v10.expected.png")]
+ [InlineData("v10 - with folder.cbz", "v10 - with folder.expected.png")]
+ [InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.png")]
+ [InlineData("macos_native.zip", "macos_native.png")]
+ [InlineData("v10 - duplicate covers.cbz", "v10 - duplicate covers.expected.png")]
+ [InlineData("sorting.zip", "sorting.expected.png")]
+ [InlineData("test.zip", "test.expected.jpg")]
public void GetCoverImage_Default_Test(string inputFile, string expectedOutputFile)
{
var ds = Substitute.For(_directoryServiceLogger, new FileSystem());
@@ -183,33 +181,33 @@ namespace API.Tests.Services
Assert.Equal(expectedBytes, actual);
- //_directoryService.ClearAndDeleteDirectory(outputDir);
+ _directoryService.ClearAndDeleteDirectory(outputDir);
}
- // TODO: This is broken on GA due to DirectoryService.CoverImageDirectory
- //[Theory]
- [InlineData("v10.cbz", "v10.expected.jpg")]
- [InlineData("v10 - with folder.cbz", "v10 - with folder.expected.jpg")]
- [InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.jpg")]
- [InlineData("macos_native.zip", "macos_native.jpg")]
- [InlineData("v10 - duplicate covers.cbz", "v10 - duplicate covers.expected.jpg")]
- [InlineData("sorting.zip", "sorting.expected.jpg")]
+ [Theory]
+ [InlineData("v10.cbz", "v10.expected.png")]
+ [InlineData("v10 - with folder.cbz", "v10 - with folder.expected.png")]
+ [InlineData("v10 - nested folder.cbz", "v10 - nested folder.expected.png")]
+ [InlineData("macos_native.zip", "macos_native.png")]
+ [InlineData("v10 - duplicate covers.cbz", "v10 - duplicate covers.expected.png")]
+ [InlineData("sorting.zip", "sorting.expected.png")]
public void GetCoverImage_SharpCompress_Test(string inputFile, string expectedOutputFile)
{
var imageService = new ImageService(Substitute.For>(), _directoryService);
var archiveService = Substitute.For(_logger,
new DirectoryService(_directoryServiceLogger, new FileSystem()), imageService);
- var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages");
- var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile));
+ var testDirectory = API.Parser.Parser.NormalizePath(Path.GetFullPath(Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/CoverImages")));
var outputDir = Path.Join(testDirectory, "output");
_directoryService.ClearDirectory(outputDir);
_directoryService.ExistOrCreate(outputDir);
archiveService.Configure().CanOpen(Path.Join(testDirectory, inputFile)).Returns(ArchiveLibrary.SharpCompress);
- var actualBytes = File.ReadAllBytes(archiveService.GetCoverImage(Path.Join(testDirectory, inputFile),
- Path.GetFileNameWithoutExtension(inputFile) + "_output", outputDir));
+ var coverOutputFile = archiveService.GetCoverImage(Path.Join(testDirectory, inputFile),
+ Path.GetFileNameWithoutExtension(inputFile), outputDir);
+ var actualBytes = File.ReadAllBytes(Path.Join(outputDir, coverOutputFile));
+ var expectedBytes = File.ReadAllBytes(Path.Join(testDirectory, expectedOutputFile));
Assert.Equal(expectedBytes, actualBytes);
_directoryService.ClearAndDeleteDirectory(outputDir);
@@ -310,7 +308,7 @@ namespace API.Tests.Services
[InlineData(new [] {"001.txt", "002.txt", "a.jpg"}, "Test.zip", "a.jpg")]
public void FindCoverImageFilename(string[] filenames, string archiveName, string expected)
{
- Assert.Equal(expected, _archiveService.FindCoverImageFilename(archiveName, filenames));
+ Assert.Equal(expected, ArchiveService.FindCoverImageFilename(archiveName, filenames));
}
diff --git a/API.Tests/Services/BackupServiceTests.cs b/API.Tests/Services/BackupServiceTests.cs
index 1af01632c..31896a38c 100644
--- a/API.Tests/Services/BackupServiceTests.cs
+++ b/API.Tests/Services/BackupServiceTests.cs
@@ -26,7 +26,7 @@ public class BackupServiceTests
{
private readonly ILogger _logger = Substitute.For>();
private readonly IUnitOfWork _unitOfWork;
- private readonly IHubContext _messageHub = Substitute.For>();
+ private readonly IEventHub _messageHub = Substitute.For();
private readonly IConfiguration _config;
private readonly DbConnection _connection;
diff --git a/API.Tests/Services/BookmarkServiceTests.cs b/API.Tests/Services/BookmarkServiceTests.cs
index 5f862d35f..0026ea678 100644
--- a/API.Tests/Services/BookmarkServiceTests.cs
+++ b/API.Tests/Services/BookmarkServiceTests.cs
@@ -11,6 +11,7 @@ using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;
using API.Services;
+using API.SignalR;
using AutoMapper;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
@@ -334,4 +335,65 @@ public class BookmarkServiceTests
Assert.False(ds.FileSystem.FileInfo.FromFileName(Path.Join(BookmarkDirectory, "1/1/1/0001.jpg")).Exists);
}
#endregion
+
+ #region GetBookmarkFilesById
+
+ [Fact]
+ public async Task GetBookmarkFilesById_ShouldMatchActualFiles()
+ {
+ var filesystem = CreateFileSystem();
+ filesystem.AddFile($"{CacheDirectory}1/0001.jpg", new MockFileData("123"));
+
+ // Delete all Series to reset state
+ await ResetDB();
+
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ new Volume()
+ {
+ Chapters = new List()
+ {
+ new Chapter()
+ {
+
+ }
+ }
+ }
+ }
+ });
+
+ _context.AppUser.Add(new AppUser()
+ {
+ UserName = "Joe"
+ });
+
+ await _context.SaveChangesAsync();
+
+
+ var ds = new DirectoryService(Substitute.For>(), filesystem);
+ var bookmarkService = new BookmarkService(Substitute.For>(), _unitOfWork, ds);
+ var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Bookmarks);
+
+ await bookmarkService.BookmarkPage(user, new BookmarkDto()
+ {
+ ChapterId = 1,
+ Page = 1,
+ SeriesId = 1,
+ VolumeId = 1
+ }, $"{CacheDirectory}1/0001.jpg");
+
+ var files = await bookmarkService.GetBookmarkFilesById(1, new[] {1});
+ var actualFiles = ds.GetFiles(BookmarkDirectory, searchOption: SearchOption.AllDirectories);
+ Assert.Equal(files.Select(API.Parser.Parser.NormalizePath).ToList(), actualFiles.Select(API.Parser.Parser.NormalizePath).ToList());
+ }
+
+
+ #endregion
}
diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs
index 44cba64a4..1dd131112 100644
--- a/API.Tests/Services/CleanupServiceTests.cs
+++ b/API.Tests/Services/CleanupServiceTests.cs
@@ -26,7 +26,7 @@ public class CleanupServiceTests
{
private readonly ILogger _logger = Substitute.For>();
private readonly IUnitOfWork _unitOfWork;
- private readonly IHubContext _messageHub = Substitute.For>();
+ private readonly IEventHub _messageHub = Substitute.For();
private readonly DbConnection _connection;
private readonly DataContext _context;
diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs
index 391b4eac4..c0d49820b 100644
--- a/API.Tests/Services/DirectoryServiceTests.cs
+++ b/API.Tests/Services/DirectoryServiceTests.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions.TestingHelpers;
@@ -755,6 +755,22 @@ namespace API.Tests.Services
Assert.True(fileSystem.Directory.Exists($"{testDirectory}subdir/"));
}
+ [Fact]
+ public void Flatten_ShouldFlatten_WithoutMacosx()
+ {
+ const string testDirectory = "/manga/";
+ var fileSystem = new MockFileSystem();
+ fileSystem.AddDirectory(testDirectory);
+ fileSystem.AddFile($"{testDirectory}data-1.jpg", new MockFileData("abc"));
+ fileSystem.AddFile($"{testDirectory}subdir/data-3.webp", new MockFileData("abc"));
+ fileSystem.AddFile($"{testDirectory}__MACOSX/data-4.webp", new MockFileData("abc"));
+
+ var ds = new DirectoryService(Substitute.For>(), fileSystem);
+ ds.Flatten($"{testDirectory}");
+ Assert.Equal(2, ds.GetFiles(testDirectory).Count());
+ Assert.False(fileSystem.FileExists($"{testDirectory}data-4.webp"));
+ }
+
#endregion
#region CheckWriteAccess
diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs
index e3b0b498f..39f990bbf 100644
--- a/API.Tests/Services/ParseScannedFilesTests.cs
+++ b/API.Tests/Services/ParseScannedFilesTests.cs
@@ -11,6 +11,7 @@ using API.Entities.Enums;
using API.Parser;
using API.Services;
using API.Services.Tasks.Scanner;
+using API.SignalR;
using API.Tests.Helpers;
using AutoMapper;
using Microsoft.Data.Sqlite;
@@ -155,7 +156,7 @@ public class ParseScannedFilesTests
var fileSystem = new MockFileSystem();
var ds = new DirectoryService(Substitute.For>(), fileSystem);
var psf = new ParseScannedFiles(Substitute.For>(), ds,
- new MockReadingItemService(new DefaultParser(ds)));
+ new MockReadingItemService(new DefaultParser(ds)), Substitute.For());
var infos = new List()
{
@@ -200,7 +201,7 @@ public class ParseScannedFilesTests
var fileSystem = new MockFileSystem();
var ds = new DirectoryService(Substitute.For>(), fileSystem);
var psf = new ParseScannedFiles(Substitute.For>(), ds,
- new MockReadingItemService(new DefaultParser(ds)));
+ new MockReadingItemService(new DefaultParser(ds)), Substitute.For());
var infos = new List()
{
@@ -240,7 +241,7 @@ public class ParseScannedFilesTests
#region MergeName
[Fact]
- public void MergeName_ShouldMergeMatchingFormatAndName()
+ public async Task MergeName_ShouldMergeMatchingFormatAndName()
{
var fileSystem = new MockFileSystem();
fileSystem.AddDirectory("C:/Data/");
@@ -250,10 +251,10 @@ public class ParseScannedFilesTests
var ds = new DirectoryService(Substitute.For>(), fileSystem);
var psf = new ParseScannedFiles(Substitute.For>(), ds,
- new MockReadingItemService(new DefaultParser(ds)));
+ new MockReadingItemService(new DefaultParser(ds)), Substitute.For());
- psf.ScanLibrariesForSeries(LibraryType.Manga, new List() {"C:/Data/"}, out _, out _);
+ await psf.ScanLibrariesForSeries(LibraryType.Manga, new List() {"C:/Data/"}, "libraryName");
Assert.Equal("Accel World", psf.MergeName(ParserInfoFactory.CreateParsedInfo("Accel World", "1", "0", "Accel World v1.cbz", false)));
Assert.Equal("Accel World", psf.MergeName(ParserInfoFactory.CreateParsedInfo("accel_world", "1", "0", "Accel World v1.cbz", false)));
@@ -261,7 +262,7 @@ public class ParseScannedFilesTests
}
[Fact]
- public void MergeName_ShouldMerge_MismatchedFormatSameName()
+ public async Task MergeName_ShouldMerge_MismatchedFormatSameName()
{
var fileSystem = new MockFileSystem();
fileSystem.AddDirectory("C:/Data/");
@@ -271,10 +272,10 @@ public class ParseScannedFilesTests
var ds = new DirectoryService(Substitute.For>(), fileSystem);
var psf = new ParseScannedFiles(Substitute.For>(), ds,
- new MockReadingItemService(new DefaultParser(ds)));
+ new MockReadingItemService(new DefaultParser(ds)), Substitute.For());
- psf.ScanLibrariesForSeries(LibraryType.Manga, new List() {"C:/Data/"}, out _, out _);
+ await psf.ScanLibrariesForSeries(LibraryType.Manga, new List() {"C:/Data/"}, "libraryName");
Assert.Equal("Accel World", psf.MergeName(ParserInfoFactory.CreateParsedInfo("Accel World", "1", "0", "Accel World v1.epub", false)));
Assert.Equal("Accel World", psf.MergeName(ParserInfoFactory.CreateParsedInfo("accel_world", "1", "0", "Accel World v1.epub", false)));
@@ -285,7 +286,7 @@ public class ParseScannedFilesTests
#region ScanLibrariesForSeries
[Fact]
- public void ScanLibrariesForSeries_ShouldFindFiles()
+ public async Task ScanLibrariesForSeries_ShouldFindFiles()
{
var fileSystem = new MockFileSystem();
fileSystem.AddDirectory("C:/Data/");
@@ -296,10 +297,10 @@ public class ParseScannedFilesTests
var ds = new DirectoryService(Substitute.For>(), fileSystem);
var psf = new ParseScannedFiles(Substitute.For>(), ds,
- new MockReadingItemService(new DefaultParser(ds)));
+ new MockReadingItemService(new DefaultParser(ds)), Substitute.For());
- var parsedSeries = psf.ScanLibrariesForSeries(LibraryType.Manga, new List() {"C:/Data/"}, out _, out _);
+ var parsedSeries = await psf.ScanLibrariesForSeries(LibraryType.Manga, new List() {"C:/Data/"}, "libraryName");
Assert.Equal(3, parsedSeries.Values.Count);
Assert.NotEmpty(parsedSeries.Keys.Where(p => p.Format == MangaFormat.Archive && p.Name.Equals("Accel World")));
diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs
index 05d076b3f..8439c69d3 100644
--- a/API.Tests/Services/ReaderServiceTests.cs
+++ b/API.Tests/Services/ReaderServiceTests.cs
@@ -595,7 +595,7 @@ public class ReaderServiceTests
}
[Fact]
- public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter()
+ public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromLastChapter_NoSpecials()
{
await ResetDB();
@@ -636,7 +636,7 @@ public class ReaderServiceTests
}
[Fact]
- public async Task GetNextChapterIdAsync_ShouldMoveFromVolumeToSpecial()
+ public async Task GetNextChapterIdAsync_ShouldMoveFromVolumeToSpecial_NoLooseLeafChapters()
{
await ResetDB();
@@ -678,6 +678,87 @@ public class ReaderServiceTests
Assert.Equal("A.cbz", actualChapter.Range);
}
+ [Fact]
+ public async Task GetNextChapterIdAsync_ShouldMoveFromLooseLeafChapterToSpecial()
+ {
+ await ResetDB();
+
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ EntityFactory.CreateVolume("0", new List()
+ {
+ EntityFactory.CreateChapter("1", false, new List()),
+ EntityFactory.CreateChapter("2", false, new List()),
+ EntityFactory.CreateChapter("A.cbz", true, new List()),
+ }),
+ }
+ });
+
+ _context.AppUser.Add(new AppUser()
+ {
+ UserName = "majora2007"
+ });
+
+ await _context.SaveChangesAsync();
+
+ var readerService = new ReaderService(_unitOfWork, Substitute.For>());
+
+
+ var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1);
+ Assert.NotEqual(-1, nextChapter);
+ var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter);
+ Assert.Equal("A.cbz", actualChapter.Range);
+ }
+
+ [Fact]
+ public async Task GetNextChapterIdAsync_ShouldFindNoNextChapterFromSpecial_WithVolumeAndLooseLeafChapters()
+ {
+ await ResetDB();
+
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ EntityFactory.CreateVolume("0", new List()
+ {
+ EntityFactory.CreateChapter("1", false, new List()),
+ EntityFactory.CreateChapter("2", false, new List()),
+ EntityFactory.CreateChapter("A.cbz", true, new List()),
+ }),
+ EntityFactory.CreateVolume("1", new List()
+ {
+ EntityFactory.CreateChapter("0", false, new List()),
+ }),
+ }
+ });
+
+ _context.AppUser.Add(new AppUser()
+ {
+ UserName = "majora2007"
+ });
+
+ await _context.SaveChangesAsync();
+
+ var readerService = new ReaderService(_unitOfWork, Substitute.For>());
+
+
+ var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 3, 1);
+ Assert.Equal(-1, nextChapter);
+ }
+
+
[Fact]
public async Task GetNextChapterIdAsync_ShouldMoveFromSpecialToSpecial()
{
@@ -820,7 +901,7 @@ public class ReaderServiceTests
}
[Fact]
- public async Task GetPrevChapterIdAsync_ShouldMoveFromVolumeToSpecial()
+ public async Task GetPrevChapterIdAsync_ShouldMoveFromSpecialToVolume()
{
await ResetDB();
@@ -856,10 +937,10 @@ public class ReaderServiceTests
var readerService = new ReaderService(_unitOfWork, Substitute.For>());
- var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1);
- Assert.NotEqual(-1, prevChapter);
+ var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2, 3, 1);
+ Assert.Equal(2, prevChapter);
var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter);
- Assert.Equal("B.cbz", actualChapter.Range);
+ Assert.Equal("2", actualChapter.Range);
}
[Fact]
@@ -973,6 +1054,60 @@ public class ReaderServiceTests
Assert.Equal(-1, prevChapter);
}
+ [Fact]
+ public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromVolumeWithZeroChapterAndHasNormalChapters2()
+ {
+ await ResetDB();
+
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ EntityFactory.CreateVolume("0", new List()
+ {
+ EntityFactory.CreateChapter("5", false, new List()),
+ EntityFactory.CreateChapter("6", false, new List()),
+ EntityFactory.CreateChapter("7", false, new List()),
+
+ }),
+ EntityFactory.CreateVolume("1", new List()
+ {
+ EntityFactory.CreateChapter("1", false, new List()),
+ EntityFactory.CreateChapter("2", false, new List()),
+ }),
+ EntityFactory.CreateVolume("2", new List()
+ {
+ EntityFactory.CreateChapter("3", false, new List()),
+ EntityFactory.CreateChapter("4", false, new List()),
+ }),
+ }
+ });
+
+ _context.AppUser.Add(new AppUser()
+ {
+ UserName = "majora2007"
+ });
+
+ await _context.SaveChangesAsync();
+
+ var readerService = new ReaderService(_unitOfWork, Substitute.For>());
+
+ var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2,5, 1);
+ var chapterInfoDto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(prevChapter);
+ Assert.Equal(1, float.Parse(chapterInfoDto.ChapterNumber));
+
+ // This is first chapter of first volume
+ prevChapter = await readerService.GetPrevChapterIdAsync(1, 2,4, 1);
+ Assert.Equal(-1, prevChapter);
+ //chapterInfoDto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(prevChapter);
+
+ }
+
[Fact]
public async Task GetPrevChapterIdAsync_ShouldFindNoPrevChapterFromChapter()
{
@@ -1098,6 +1233,56 @@ public class ReaderServiceTests
#region GetContinuePoint
+ [Fact]
+ public async Task GetContinuePoint_ShouldReturnFirstVolume_NoProgress()
+ {
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ EntityFactory.CreateVolume("0", new List()
+ {
+ EntityFactory.CreateChapter("95", false, new List(), 1),
+ EntityFactory.CreateChapter("96", false, new List(), 1),
+ }),
+ EntityFactory.CreateVolume("1", new List()
+ {
+ EntityFactory.CreateChapter("1", false, new List(), 1),
+ EntityFactory.CreateChapter("2", false, new List(), 1),
+ }),
+ EntityFactory.CreateVolume("2", new List()
+ {
+ EntityFactory.CreateChapter("21", false, new List(), 1),
+ EntityFactory.CreateChapter("22", false, new List(), 1),
+ }),
+ EntityFactory.CreateVolume("3", new List()
+ {
+ EntityFactory.CreateChapter("31", false, new List(), 1),
+ EntityFactory.CreateChapter("32", false, new List(), 1),
+ }),
+ }
+ });
+
+ _context.AppUser.Add(new AppUser()
+ {
+ UserName = "majora2007"
+ });
+
+ await _context.SaveChangesAsync();
+
+
+ var readerService = new ReaderService(_unitOfWork, Substitute.For>());
+
+ var nextChapter = await readerService.GetContinuePoint(1, 1);
+
+ Assert.Equal("1", nextChapter.Range);
+ }
+
[Fact]
public async Task GetContinuePoint_ShouldReturnFirstNonSpecial()
{
@@ -1168,6 +1353,84 @@ public class ReaderServiceTests
Assert.Equal("22", nextChapter.Range);
+ }
+
+ [Fact]
+ public async Task GetContinuePoint_ShouldReturnFirstNonSpecial2()
+ {
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ // Loose chapters
+ EntityFactory.CreateVolume("0", new List()
+ {
+ EntityFactory.CreateChapter("45", false, new List(), 1),
+ EntityFactory.CreateChapter("46", false, new List(), 1),
+ EntityFactory.CreateChapter("47", false, new List(), 1),
+ EntityFactory.CreateChapter("48", false, new List(), 1),
+ EntityFactory.CreateChapter("Some Special Title", true, new List(), 1),
+ }),
+
+ // One file volume
+ EntityFactory.CreateVolume("1", new List()
+ {
+ EntityFactory.CreateChapter("0", false, new List(), 1), // Read
+ }),
+ // Chapter-based volume
+ EntityFactory.CreateVolume("2", new List()
+ {
+ EntityFactory.CreateChapter("21", false, new List(), 1), // Read
+ EntityFactory.CreateChapter("22", false, new List(), 1),
+ }),
+ // Chapter-based volume
+ EntityFactory.CreateVolume("3", new List()
+ {
+ EntityFactory.CreateChapter("31", false, new List(), 1),
+ EntityFactory.CreateChapter("32", false, new List(), 1),
+ }),
+ }
+ });
+
+ _context.AppUser.Add(new AppUser()
+ {
+ UserName = "majora2007"
+ });
+
+ await _context.SaveChangesAsync();
+
+ var readerService = new ReaderService(_unitOfWork, Substitute.For>());
+
+ // Save progress on first volume and 1st chapter of second volume
+ await readerService.SaveReadingProgress(new ProgressDto()
+ {
+ PageNum = 1,
+ ChapterId = 6, // Chapter 0 volume 1 id
+ SeriesId = 1,
+ VolumeId = 2 // Volume 1 id
+ }, 1);
+
+
+ await readerService.SaveReadingProgress(new ProgressDto()
+ {
+ PageNum = 1,
+ ChapterId = 7, // Chapter 21 volume 2 id
+ SeriesId = 1,
+ VolumeId = 3 // Volume 2 id
+ }, 1);
+
+ await _context.SaveChangesAsync();
+
+ var nextChapter = await readerService.GetContinuePoint(1, 1);
+
+ Assert.Equal("22", nextChapter.Range);
+
+
}
[Fact]
@@ -1239,6 +1502,48 @@ public class ReaderServiceTests
Assert.Equal("31", nextChapter.Range);
}
+ [Fact]
+ public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenNonRead_LooseLeafChaptersAndVolumes()
+ {
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ EntityFactory.CreateVolume("0", new List()
+ {
+ EntityFactory.CreateChapter("230", false, new List(), 1),
+ EntityFactory.CreateChapter("231", false, new List(), 1),
+ }),
+ EntityFactory.CreateVolume("1", new List()
+ {
+ EntityFactory.CreateChapter("1", false, new List(), 1),
+ EntityFactory.CreateChapter("2", false, new List(), 1),
+ }),
+ EntityFactory.CreateVolume("2", new List()
+ {
+ EntityFactory.CreateChapter("21", false, new List(), 1),
+ }),
+ }
+ });
+
+ _context.AppUser.Add(new AppUser()
+ {
+ UserName = "majora2007"
+ });
+
+ await _context.SaveChangesAsync();
+
+ var readerService = new ReaderService(_unitOfWork, Substitute.For>());
+ var nextChapter = await readerService.GetContinuePoint(1, 1);
+
+ Assert.Equal("1", nextChapter.Range);
+ }
+
[Fact]
public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenAllRead()
{
@@ -1320,6 +1625,11 @@ public class ReaderServiceTests
EntityFactory.CreateChapter("2", false, new List(), 1),
EntityFactory.CreateChapter("3", false, new List(), 1),
}),
+ EntityFactory.CreateVolume("1", new List()
+ {
+ EntityFactory.CreateChapter("11", false, new List(), 1),
+ EntityFactory.CreateChapter("22", false, new List(), 1),
+ }),
}
});
@@ -1333,33 +1643,13 @@ public class ReaderServiceTests
var readerService = new ReaderService(_unitOfWork, Substitute.For>());
// Save progress on first volume chapters and 1st of second volume
- await readerService.SaveReadingProgress(new ProgressDto()
- {
- PageNum = 1,
- ChapterId = 1,
- SeriesId = 1,
- VolumeId = 1
- }, 1);
- await readerService.SaveReadingProgress(new ProgressDto()
- {
- PageNum = 1,
- ChapterId = 2,
- SeriesId = 1,
- VolumeId = 1
- }, 1);
- await readerService.SaveReadingProgress(new ProgressDto()
- {
- PageNum = 1,
- ChapterId = 3,
- SeriesId = 1,
- VolumeId = 1
- }, 1);
-
+ var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress);
+ await readerService.MarkSeriesAsRead(user, 1);
await _context.SaveChangesAsync();
var nextChapter = await readerService.GetContinuePoint(1, 1);
- Assert.Equal("1", nextChapter.Range);
+ Assert.Equal("11", nextChapter.Range);
}
[Fact]
@@ -1423,6 +1713,61 @@ public class ReaderServiceTests
Assert.Equal("Some Special Title", nextChapter.Range);
}
+ [Fact]
+ public async Task GetContinuePoint_ShouldReturnFirstVolumeChapter_WhenPreExistingProgress()
+ {
+ var series = new Series()
+ {
+ Name = "Test",
+ Library = new Library()
+ {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ EntityFactory.CreateVolume("0", new List()
+ {
+ EntityFactory.CreateChapter("230", false, new List(), 1),
+ //EntityFactory.CreateChapter("231", false, new List(), 1), (added later)
+ }),
+ EntityFactory.CreateVolume("1", new List()
+ {
+ EntityFactory.CreateChapter("1", false, new List(), 1),
+ EntityFactory.CreateChapter("2", false, new List(), 1),
+ }),
+ EntityFactory.CreateVolume("2", new List()
+ {
+ EntityFactory.CreateChapter("0", false, new List(), 1),
+ //EntityFactory.CreateChapter("14.9", false, new List(), 1), (added later)
+ }),
+ }
+ };
+ _context.Series.Add(series);
+
+ _context.AppUser.Add(new AppUser()
+ {
+ UserName = "majora2007"
+ });
+
+ await _context.SaveChangesAsync();
+
+
+ var readerService = new ReaderService(_unitOfWork, Substitute.For>());
+ var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress);
+ await readerService.MarkSeriesAsRead(user, 1);
+ await _context.SaveChangesAsync();
+
+ // Add 2 new unread series to the Series
+ series.Volumes[0].Chapters.Add(EntityFactory.CreateChapter("231", false, new List(), 1));
+ series.Volumes[2].Chapters.Add(EntityFactory.CreateChapter("14.9", false, new List(), 1));
+ _context.Series.Attach(series);
+ await _context.SaveChangesAsync();
+
+ var nextChapter = await readerService.GetContinuePoint(1, 1);
+ Assert.Equal("14.9", nextChapter.Range);
+ }
+
#endregion
#region MarkChaptersUntilAsRead
@@ -1514,7 +1859,7 @@ public class ReaderServiceTests
}
[Fact]
- public async Task MarkChaptersUntilAsRead_ShouldNotReadOnlyVolumesWithChapter0()
+ public async Task MarkChaptersUntilAsRead_ShouldMarkAsRead_OnlyVolumesWithChapter0()
{
_context.Series.Add(new Series()
{
@@ -1550,11 +1895,204 @@ public class ReaderServiceTests
await _context.SaveChangesAsync();
// Validate correct chapters have read status
- Assert.False(await _unitOfWork.AppUserProgressRepository.UserHasProgress(LibraryType.Manga, 1));
+ Assert.True(await _unitOfWork.AppUserProgressRepository.UserHasProgress(LibraryType.Manga, 1));
+ }
+
+ [Fact]
+ public async Task MarkChaptersUntilAsRead_ShouldMarkAsReadAnythingUntil()
+ {
+ await ResetDB();
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library()
+ {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ EntityFactory.CreateVolume("0", new List()
+ {
+ EntityFactory.CreateChapter("45", false, new List(), 5),
+
+ EntityFactory.CreateChapter("46", false, new List(), 46),
+ EntityFactory.CreateChapter("47", false, new List(), 47),
+ EntityFactory.CreateChapter("48", false, new List(), 48),
+ EntityFactory.CreateChapter("49", false, new List(), 49),
+ EntityFactory.CreateChapter("50", false, new List(), 50),
+ EntityFactory.CreateChapter("Some Special Title", true, new List(), 10),
+ }),
+ EntityFactory.CreateVolume("1", new List()
+ {
+ EntityFactory.CreateChapter("0", false, new List(), 6),
+ }),
+ EntityFactory.CreateVolume("2", new List()
+ {
+ EntityFactory.CreateChapter("0", false, new List(), 7),
+ }),
+ EntityFactory.CreateVolume("3", new List()
+ {
+ EntityFactory.CreateChapter("12", false, new List(), 5),
+ EntityFactory.CreateChapter("13", false, new List(), 5),
+ EntityFactory.CreateChapter("14", false, new List(), 5),
+ }),
+ }
+ });
+
+ _context.AppUser.Add(new AppUser()
+ {
+ UserName = "majora2007"
+ });
+
+ await _context.SaveChangesAsync();
+
+ var readerService = new ReaderService(_unitOfWork, Substitute.For>());
+
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress);
+ const int markReadUntilNumber = 47;
+
+ await readerService.MarkChaptersUntilAsRead(user, 1, markReadUntilNumber);
+ await _context.SaveChangesAsync();
+
+ var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(1, 1);
+ Assert.True(volumes.SelectMany(v => v.Chapters).All(c =>
+ {
+ // Specials are ignored.
+ var notReadChapterRanges = new[] {"Some Special Title", "48", "49", "50"};
+ if (notReadChapterRanges.Contains(c.Range))
+ {
+ return c.PagesRead == 0;
+ }
+ // Pages read and total pages must match -> chapter fully read
+ return c.Pages == c.PagesRead;
+
+ }));
}
#endregion
+ #region MarkSeriesAsRead
+ [Fact]
+ public async Task MarkSeriesAsReadTest()
+ {
+ await ResetDB();
+
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ new Volume()
+ {
+ Chapters = new List()
+ {
+ new Chapter()
+ {
+ Pages = 1
+ },
+ new Chapter()
+ {
+ Pages = 2
+ }
+ }
+ },
+ new Volume()
+ {
+ Chapters = new List()
+ {
+ new Chapter()
+ {
+ Pages = 1
+ },
+ new Chapter()
+ {
+ Pages = 2
+ }
+ }
+ }
+ }
+ });
+
+ _context.AppUser.Add(new AppUser()
+ {
+ UserName = "majora2007"
+ });
+
+ await _context.SaveChangesAsync();
+
+ var readerService = new ReaderService(_unitOfWork, Substitute.For>());
+
+ await readerService.MarkSeriesAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1);
+ await _context.SaveChangesAsync();
+
+ Assert.Equal(4, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count);
+ }
+
+
+ #endregion
+
+ #region MarkSeriesAsUnread
+
+ [Fact]
+ public async Task MarkSeriesAsUnreadTest()
+ {
+ await ResetDB();
+
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ new Volume()
+ {
+ Chapters = new List()
+ {
+ new Chapter()
+ {
+ Pages = 1
+ },
+ new Chapter()
+ {
+ Pages = 2
+ }
+ }
+ }
+ }
+ });
+
+ _context.AppUser.Add(new AppUser()
+ {
+ UserName = "majora2007"
+ });
+
+ await _context.SaveChangesAsync();
+
+ var readerService = new ReaderService(_unitOfWork, Substitute.For>());
+
+ var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList();
+ readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters);
+
+ await _context.SaveChangesAsync();
+ Assert.Equal(2, (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses.Count);
+
+ await readerService.MarkSeriesAsUnread(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1);
+ await _context.SaveChangesAsync();
+
+ var progresses = (await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress)).Progresses;
+ Assert.Equal(0, progresses.Max(p => p.PagesRead));
+ Assert.Equal(2, progresses.Count);
+ }
+
+ #endregion
}
diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs
new file mode 100644
index 000000000..42b586e1f
--- /dev/null
+++ b/API.Tests/Services/SeriesServiceTests.cs
@@ -0,0 +1,572 @@
+using System.Collections.Generic;
+using System.Data.Common;
+using System.IO.Abstractions.TestingHelpers;
+using System.Linq;
+using System.Threading.Tasks;
+using API.Data;
+using API.Data.Repositories;
+using API.DTOs;
+using API.Entities;
+using API.Entities.Enums;
+using API.Helpers;
+using API.Services;
+using API.SignalR;
+using API.Tests.Helpers;
+using AutoMapper;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Xunit;
+
+namespace API.Tests.Services;
+
+public class SeriesServiceTests
+{
+ private readonly IUnitOfWork _unitOfWork;
+
+ private readonly DbConnection _connection;
+ private readonly DataContext _context;
+
+ private readonly ISeriesService _seriesService;
+
+ private const string CacheDirectory = "C:/kavita/config/cache/";
+ private const string CoverImageDirectory = "C:/kavita/config/covers/";
+ private const string BackupDirectory = "C:/kavita/config/backups/";
+ private const string DataDirectory = "C:/data/";
+
+ public SeriesServiceTests()
+ {
+ var contextOptions = new DbContextOptionsBuilder().UseSqlite(CreateInMemoryDatabase()).Options;
+ _connection = RelationalOptionsExtension.Extract(contextOptions).Connection;
+
+ _context = new DataContext(contextOptions);
+ Task.Run(SeedDb).GetAwaiter().GetResult();
+
+ var config = new MapperConfiguration(cfg => cfg.AddProfile());
+ var mapper = config.CreateMapper();
+ _unitOfWork = new UnitOfWork(_context, mapper, null);
+
+ _seriesService = new SeriesService(_unitOfWork, Substitute.For(),
+ Substitute.For(), Substitute.For>());
+ }
+ #region Setup
+
+ private static DbConnection CreateInMemoryDatabase()
+ {
+ var connection = new SqliteConnection("Filename=:memory:");
+
+ connection.Open();
+
+ return connection;
+ }
+
+ private async Task SeedDb()
+ {
+ await _context.Database.MigrateAsync();
+ var filesystem = CreateFileSystem();
+
+ await Seed.SeedSettings(_context,
+ new DirectoryService(Substitute.For>(), filesystem));
+
+ var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();
+ setting.Value = CacheDirectory;
+
+ setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync();
+ setting.Value = BackupDirectory;
+
+ _context.ServerSetting.Update(setting);
+
+ var lib = new Library()
+ {
+ Name = "Manga", Folders = new List() {new FolderPath() {Path = "C:/data/"}}
+ };
+
+ _context.AppUser.Add(new AppUser()
+ {
+ UserName = "majora2007",
+ Libraries = new List()
+ {
+ lib
+ }
+ });
+
+ return await _context.SaveChangesAsync() > 0;
+ }
+
+ private async Task ResetDb()
+ {
+ _context.Series.RemoveRange(_context.Series.ToList());
+ _context.AppUserRating.RemoveRange(_context.AppUserRating.ToList());
+
+ await _context.SaveChangesAsync();
+ }
+
+ private static MockFileSystem CreateFileSystem()
+ {
+ var fileSystem = new MockFileSystem();
+ fileSystem.Directory.SetCurrentDirectory("C:/kavita/");
+ fileSystem.AddDirectory("C:/kavita/config/");
+ fileSystem.AddDirectory(CacheDirectory);
+ fileSystem.AddDirectory(CoverImageDirectory);
+ fileSystem.AddDirectory(BackupDirectory);
+ fileSystem.AddDirectory(DataDirectory);
+
+ return fileSystem;
+ }
+
+ #endregion
+
+ #region SeriesDetail
+
+ [Fact]
+ public async Task SeriesDetail_ShouldReturnSpecials()
+ {
+ await ResetDb();
+
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ EntityFactory.CreateVolume("0", new List()
+ {
+ EntityFactory.CreateChapter("Omake", true, new List()),
+ EntityFactory.CreateChapter("Something SP02", true, new List()),
+ }),
+ EntityFactory.CreateVolume("2", new List()
+ {
+ EntityFactory.CreateChapter("21", false, new List()),
+ EntityFactory.CreateChapter("22", false, new List()),
+ }),
+ EntityFactory.CreateVolume("3", new List()
+ {
+ EntityFactory.CreateChapter("31", false, new List()),
+ EntityFactory.CreateChapter("32", false, new List()),
+ }),
+ }
+ });
+
+ await _context.SaveChangesAsync();
+
+ var expectedRanges = new[] {"Omake", "Something SP02"};
+
+ var detail = await _seriesService.GetSeriesDetail(1, 1);
+ Assert.NotEmpty(detail.Specials);
+ Assert.True(2 == detail.Specials.Count());
+ Assert.All(detail.Specials, dto => Assert.Contains(dto.Range, expectedRanges));
+ }
+
+ [Fact]
+ public async Task SeriesDetail_ShouldReturnVolumesAndChapters()
+ {
+ await ResetDb();
+
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ EntityFactory.CreateVolume("0", new List()
+ {
+ EntityFactory.CreateChapter("1", false, new List()),
+ EntityFactory.CreateChapter("2", false, new List()),
+ }),
+ EntityFactory.CreateVolume("2", new List()
+ {
+ EntityFactory.CreateChapter("21", false, new List()),
+ EntityFactory.CreateChapter("22", false, new List()),
+ }),
+ EntityFactory.CreateVolume("3", new List()
+ {
+ EntityFactory.CreateChapter("31", false, new List()),
+ EntityFactory.CreateChapter("32", false, new List()),
+ }),
+ }
+ });
+
+ await _context.SaveChangesAsync();
+
+ var detail = await _seriesService.GetSeriesDetail(1, 1);
+ Assert.NotEmpty(detail.Chapters);
+ Assert.Equal(6, detail.Chapters.Count());
+
+ Assert.NotEmpty(detail.Volumes);
+ Assert.Equal(2, detail.Volumes.Count()); // Volume 0 shouldn't be sent in Volumes
+ Assert.All(detail.Volumes, dto => Assert.Contains(dto.Name, new[] {"Volume 2", "Volume 3"})); // Volumes get names mapped
+ }
+
+ [Fact]
+ public async Task SeriesDetail_ShouldReturnVolumesAndChapters_ButRemove0Chapter()
+ {
+ await ResetDb();
+
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ EntityFactory.CreateVolume("0", new List()
+ {
+ EntityFactory.CreateChapter("1", false, new List()),
+ EntityFactory.CreateChapter("2", false, new List()),
+ }),
+ EntityFactory.CreateVolume("2", new List()
+ {
+ EntityFactory.CreateChapter("0", false, new List()),
+ }),
+ EntityFactory.CreateVolume("3", new List()
+ {
+ EntityFactory.CreateChapter("31", false, new List()),
+ }),
+ }
+ });
+
+ await _context.SaveChangesAsync();
+
+ var detail = await _seriesService.GetSeriesDetail(1, 1);
+ Assert.NotEmpty(detail.Chapters);
+ // volume 2 has a 0 chapter aka a single chapter that is represented as a volume. We don't show in Chapters area
+ Assert.Equal(3, detail.Chapters.Count());
+
+ Assert.NotEmpty(detail.Volumes);
+ Assert.Equal(2, detail.Volumes.Count());
+ }
+
+ [Fact]
+ public async Task SeriesDetail_ShouldReturnChaptersOnly_WhenBookLibrary()
+ {
+ await ResetDb();
+
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Book,
+ },
+ Volumes = new List()
+ {
+ EntityFactory.CreateVolume("2", new List()
+ {
+ EntityFactory.CreateChapter("0", false, new List()),
+ }),
+ EntityFactory.CreateVolume("3", new List()
+ {
+ EntityFactory.CreateChapter("0", false, new List()),
+ }),
+ }
+ });
+
+ await _context.SaveChangesAsync();
+
+ var detail = await _seriesService.GetSeriesDetail(1, 1);
+ Assert.NotEmpty(detail.Volumes);
+
+ Assert.Empty(detail.Chapters); // A book library where all books are Volumes, will show no "chapters" on the UI because it doesn't make sense
+ Assert.Equal(2, detail.Volumes.Count());
+ }
+
+ [Fact]
+ public async Task SeriesDetail_WhenBookLibrary_ShouldReturnVolumesAndSpecial()
+ {
+ await ResetDb();
+
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Book,
+ },
+ Volumes = new List()
+ {
+ EntityFactory.CreateVolume("0", new List()
+ {
+ EntityFactory.CreateChapter("Ano Orokamono ni mo Kyakkou wo! - Volume 1.epub", true, new List()),
+ }),
+ EntityFactory.CreateVolume("2", new List()
+ {
+ EntityFactory.CreateChapter("Ano Orokamono ni mo Kyakkou wo! - Volume 2.epub", false, new List()),
+ }),
+ }
+ });
+
+ await _context.SaveChangesAsync();
+
+ var detail = await _seriesService.GetSeriesDetail(1, 1);
+ Assert.NotEmpty(detail.Volumes);
+ Assert.Equal("2 - Ano Orokamono ni mo Kyakkou wo! - Volume 2", detail.Volumes.ElementAt(0).Name);
+
+ Assert.NotEmpty(detail.Specials);
+ Assert.Equal("Ano Orokamono ni mo Kyakkou wo! - Volume 1.epub", detail.Specials.ElementAt(0).Range);
+
+ // A book library where all books are Volumes, will show no "chapters" on the UI because it doesn't make sense
+ Assert.Empty(detail.Chapters);
+
+ Assert.Equal(1, detail.Volumes.Count());
+ }
+
+ [Fact]
+ public async Task SeriesDetail_ShouldSortVolumesByName()
+ {
+ await ResetDb();
+
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Book,
+ },
+ Volumes = new List()
+ {
+ EntityFactory.CreateVolume("2", new List()
+ {
+ EntityFactory.CreateChapter("0", false, new List()),
+ }),
+ EntityFactory.CreateVolume("1.2", new List()
+ {
+ EntityFactory.CreateChapter("0", false, new List()),
+ }),
+ EntityFactory.CreateVolume("1", new List()
+ {
+ EntityFactory.CreateChapter("0", false, new List()),
+ }),
+ }
+ });
+
+ await _context.SaveChangesAsync();
+
+ var detail = await _seriesService.GetSeriesDetail(1, 1);
+ Assert.Equal("1", detail.Volumes.ElementAt(0).Name);
+ Assert.Equal("1.2", detail.Volumes.ElementAt(1).Name);
+ Assert.Equal("2", detail.Volumes.ElementAt(2).Name);
+ }
+
+
+ #endregion
+
+
+ #region UpdateRating
+
+ [Fact]
+ public async Task UpdateRating_ShouldSetRating()
+ {
+ await ResetDb();
+
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ new Volume()
+ {
+ Chapters = new List()
+ {
+ new Chapter()
+ {
+ Pages = 1
+ }
+ }
+ }
+ }
+ });
+
+ await _context.SaveChangesAsync();
+
+
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
+
+ var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
+ {
+ SeriesId = 1,
+ UserRating = 3,
+ UserReview = "Average"
+ });
+
+ Assert.True(result);
+
+ var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
+ .Ratings;
+ Assert.NotEmpty(ratings);
+ Assert.Equal(3, ratings.First().Rating);
+ Assert.Equal("Average", ratings.First().Review);
+ }
+
+ [Fact]
+ public async Task UpdateRating_ShouldUpdateExistingRating()
+ {
+ await ResetDb();
+
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ new Volume()
+ {
+ Chapters = new List()
+ {
+ new Chapter()
+ {
+ Pages = 1
+ }
+ }
+ }
+ }
+ });
+
+
+ await _context.SaveChangesAsync();
+
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
+
+ var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
+ {
+ SeriesId = 1,
+ UserRating = 3,
+ UserReview = "Average"
+ });
+
+ Assert.True(result);
+
+ var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
+ .Ratings;
+ Assert.NotEmpty(ratings);
+ Assert.Equal(3, ratings.First().Rating);
+ Assert.Equal("Average", ratings.First().Review);
+
+ // Update the DB again
+
+ var result2 = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
+ {
+ SeriesId = 1,
+ UserRating = 5,
+ UserReview = "Average"
+ });
+
+ Assert.True(result2);
+
+ var ratings2 = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
+ .Ratings;
+ Assert.NotEmpty(ratings2);
+ Assert.True(ratings2.Count == 1);
+ Assert.Equal(5, ratings2.First().Rating);
+ Assert.Equal("Average", ratings2.First().Review);
+ }
+
+ [Fact]
+ public async Task UpdateRating_ShouldClampRatingAt5()
+ {
+ await ResetDb();
+
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ new Volume()
+ {
+ Chapters = new List()
+ {
+ new Chapter()
+ {
+ Pages = 1
+ }
+ }
+ }
+ }
+ });
+
+ await _context.SaveChangesAsync();
+
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
+
+ var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
+ {
+ SeriesId = 1,
+ UserRating = 10,
+ UserReview = "Average"
+ });
+
+ Assert.True(result);
+
+ var ratings = (await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings))
+ .Ratings;
+ Assert.NotEmpty(ratings);
+ Assert.Equal(5, ratings.First().Rating);
+ Assert.Equal("Average", ratings.First().Review);
+ }
+
+ [Fact]
+ public async Task UpdateRating_ShouldReturnFalseWhenSeriesDoesntExist()
+ {
+ await ResetDb();
+
+ _context.Series.Add(new Series()
+ {
+ Name = "Test",
+ Library = new Library() {
+ Name = "Test LIb",
+ Type = LibraryType.Manga,
+ },
+ Volumes = new List()
+ {
+ new Volume()
+ {
+ Chapters = new List()
+ {
+ new Chapter()
+ {
+ Pages = 1
+ }
+ }
+ }
+ }
+ });
+
+ await _context.SaveChangesAsync();
+
+ var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Ratings);
+
+ var result = await _seriesService.UpdateRating(user, new UpdateSeriesRatingDto()
+ {
+ SeriesId = 2,
+ UserRating = 5,
+ UserReview = "Average"
+ });
+
+ Assert.False(result);
+
+ var ratings = user.Ratings;
+ Assert.Empty(ratings);
+ }
+
+ #endregion
+}
diff --git a/API.Tests/Services/SiteThemeServiceTests.cs b/API.Tests/Services/SiteThemeServiceTests.cs
new file mode 100644
index 000000000..3f3f18acf
--- /dev/null
+++ b/API.Tests/Services/SiteThemeServiceTests.cs
@@ -0,0 +1,264 @@
+using System.Collections.Generic;
+using System.Data.Common;
+using System.IO.Abstractions.TestingHelpers;
+using System.Linq;
+using System.Threading.Tasks;
+using API.Data;
+using API.Entities;
+using API.Entities.Enums;
+using API.Entities.Enums.Theme;
+using API.Helpers;
+using API.Services;
+using API.Services.Tasks;
+using API.SignalR;
+using AutoMapper;
+using Kavita.Common;
+using Microsoft.AspNetCore.SignalR;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Xunit;
+
+namespace API.Tests.Services;
+
+public class SiteThemeServiceTests
+{
+ private readonly ILogger _logger = Substitute.For>();
+ private readonly IEventHub _messageHub = Substitute.For();
+
+ private readonly DbConnection _connection;
+ private readonly DataContext _context;
+ private readonly IUnitOfWork _unitOfWork;
+
+ private const string CacheDirectory = "C:/kavita/config/cache/";
+ private const string CoverImageDirectory = "C:/kavita/config/covers/";
+ private const string BackupDirectory = "C:/kavita/config/backups/";
+ private const string BookmarkDirectory = "C:/kavita/config/bookmarks/";
+ private const string SiteThemeDirectory = "C:/kavita/config/themes/";
+
+ public SiteThemeServiceTests()
+ {
+ var contextOptions = new DbContextOptionsBuilder()
+ .UseSqlite(CreateInMemoryDatabase())
+ .Options;
+ _connection = RelationalOptionsExtension.Extract(contextOptions).Connection;
+
+ _context = new DataContext(contextOptions);
+ Task.Run(SeedDb).GetAwaiter().GetResult();
+
+ var config = new MapperConfiguration(cfg => cfg.AddProfile());
+ var mapper = config.CreateMapper();
+ _unitOfWork = new UnitOfWork(_context, mapper, null);
+ }
+
+ #region Setup
+
+ private static DbConnection CreateInMemoryDatabase()
+ {
+ var connection = new SqliteConnection("Filename=:memory:");
+
+ connection.Open();
+
+ return connection;
+ }
+
+ private async Task SeedDb()
+ {
+ await _context.Database.MigrateAsync();
+ var filesystem = CreateFileSystem();
+
+ await Seed.SeedSettings(_context, new DirectoryService(Substitute.For>(), filesystem));
+
+ var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();
+ setting.Value = CacheDirectory;
+
+ setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync();
+ setting.Value = BackupDirectory;
+
+ setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync();
+ setting.Value = BookmarkDirectory;
+
+ _context.ServerSetting.Update(setting);
+
+ _context.AppUser.Add(new AppUser()
+ {
+ UserName = "Joe",
+ UserPreferences = new AppUserPreferences
+ {
+ Theme = Seed.DefaultThemes[1]
+ }
+ });
+
+ _context.Library.Add(new Library()
+ {
+ Name = "Manga",
+ Folders = new List()
+ {
+ new FolderPath()
+ {
+ Path = "C:/data/"
+ }
+ }
+ });
+ return await _context.SaveChangesAsync() > 0;
+ }
+
+ private static MockFileSystem CreateFileSystem()
+ {
+ var fileSystem = new MockFileSystem();
+ fileSystem.Directory.SetCurrentDirectory("C:/kavita/");
+ fileSystem.AddDirectory("C:/kavita/config/");
+ fileSystem.AddDirectory(CacheDirectory);
+ fileSystem.AddDirectory(CoverImageDirectory);
+ fileSystem.AddDirectory(BackupDirectory);
+ fileSystem.AddDirectory(BookmarkDirectory);
+ fileSystem.AddDirectory(SiteThemeDirectory);
+ fileSystem.AddDirectory("C:/data/");
+
+ return fileSystem;
+ }
+
+ private async Task ResetDb()
+ {
+ _context.SiteTheme.RemoveRange(_context.SiteTheme);
+ await _context.SaveChangesAsync();
+ }
+
+ #endregion
+
+ [Fact]
+ public async Task Scan_ShouldFindCustomFile()
+ {
+ await ResetDb();
+ var filesystem = CreateFileSystem();
+ filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData(""));
+ var ds = new DirectoryService(Substitute.For>(), filesystem);
+ var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
+ await siteThemeService.Scan();
+
+ Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom"));
+ }
+
+ [Fact]
+ public async Task Scan_ShouldOnlyInsertOnceOnSecondScan()
+ {
+ await ResetDb();
+ var filesystem = CreateFileSystem();
+ filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData(""));
+ var ds = new DirectoryService(Substitute.For>(), filesystem);
+ var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
+ await siteThemeService.Scan();
+
+ Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom"));
+
+ await siteThemeService.Scan();
+
+ var customThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).Where(t =>
+ API.Parser.Parser.Normalize(t.Name).Equals(API.Parser.Parser.Normalize("custom")));
+ Assert.Single(customThemes);
+ }
+
+ [Fact]
+ public async Task Scan_ShouldDeleteWhenFileDoesntExistOnSecondScan()
+ {
+ await ResetDb();
+ var filesystem = CreateFileSystem();
+ filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData(""));
+ var ds = new DirectoryService(Substitute.For>(), filesystem);
+ var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
+ await siteThemeService.Scan();
+
+ Assert.NotNull(await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("custom"));
+
+ filesystem.RemoveFile($"{SiteThemeDirectory}custom.css");
+ await siteThemeService.Scan();
+
+ var customThemes = (await _unitOfWork.SiteThemeRepository.GetThemeDtos()).Where(t =>
+ API.Parser.Parser.Normalize(t.Name).Equals(API.Parser.Parser.Normalize("custom")));
+
+ Assert.Empty(customThemes);
+ }
+
+ [Fact]
+ public async Task GetContent_ShouldReturnContent()
+ {
+ await ResetDb();
+ var filesystem = CreateFileSystem();
+ filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
+ var ds = new DirectoryService(Substitute.For>(), filesystem);
+ var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
+
+ _context.SiteTheme.Add(new SiteTheme()
+ {
+ Name = "Custom",
+ NormalizedName = API.Parser.Parser.Normalize("Custom"),
+ Provider = ThemeProvider.User,
+ FileName = "custom.css",
+ IsDefault = false
+ });
+ await _context.SaveChangesAsync();
+
+ var content = await siteThemeService.GetContent((await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom")).Id);
+ Assert.NotNull(content);
+ Assert.NotEmpty(content);
+ Assert.Equal("123", content);
+ }
+
+ [Fact]
+ public async Task UpdateDefault_ShouldHaveOneDefault()
+ {
+ await ResetDb();
+ var filesystem = CreateFileSystem();
+ filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
+ var ds = new DirectoryService(Substitute.For>(), filesystem);
+ var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
+
+ _context.SiteTheme.Add(new SiteTheme()
+ {
+ Name = "Custom",
+ NormalizedName = API.Parser.Parser.Normalize("Custom"),
+ Provider = ThemeProvider.User,
+ FileName = "custom.css",
+ IsDefault = false
+ });
+ await _context.SaveChangesAsync();
+
+ var customTheme = (await _unitOfWork.SiteThemeRepository.GetThemeDtoByName("Custom"));
+
+ await siteThemeService.UpdateDefault(customTheme.Id);
+
+
+
+ Assert.Equal(customTheme.Id, (await _unitOfWork.SiteThemeRepository.GetDefaultTheme()).Id);
+ }
+
+ [Fact]
+ public async Task UpdateDefault_ShouldThrowOnInvalidId()
+ {
+ await ResetDb();
+ var filesystem = CreateFileSystem();
+ filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
+ var ds = new DirectoryService(Substitute.For>(), filesystem);
+ var siteThemeService = new SiteThemeService(ds, _unitOfWork, _messageHub);
+
+ _context.SiteTheme.Add(new SiteTheme()
+ {
+ Name = "Custom",
+ NormalizedName = API.Parser.Parser.Normalize("Custom"),
+ Provider = ThemeProvider.User,
+ FileName = "custom.css",
+ IsDefault = false
+ });
+ await _context.SaveChangesAsync();
+
+
+
+ var ex = await Assert.ThrowsAsync(async () => await siteThemeService.UpdateDefault(10));
+ Assert.Equal("Theme file missing or invalid", ex.Message);
+
+ }
+
+
+}
diff --git a/API.Tests/Services/Test Data/ArchiveService/CoverImages/macos_native.jpg b/API.Tests/Services/Test Data/ArchiveService/CoverImages/macos_native.jpg
deleted file mode 100644
index 575b9e556..000000000
Binary files a/API.Tests/Services/Test Data/ArchiveService/CoverImages/macos_native.jpg and /dev/null differ
diff --git a/API.Tests/Services/Test Data/ArchiveService/CoverImages/macos_native.png b/API.Tests/Services/Test Data/ArchiveService/CoverImages/macos_native.png
new file mode 100644
index 000000000..05c47779f
Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/CoverImages/macos_native.png differ
diff --git a/API.Tests/Services/Test Data/ArchiveService/CoverImages/output/test2_output.png b/API.Tests/Services/Test Data/ArchiveService/CoverImages/output/test2_output.png
deleted file mode 100644
index faa1b5d21..000000000
Binary files a/API.Tests/Services/Test Data/ArchiveService/CoverImages/output/test2_output.png and /dev/null differ
diff --git a/API.Tests/Services/Test Data/ArchiveService/CoverImages/sorting.expected.jpg b/API.Tests/Services/Test Data/ArchiveService/CoverImages/sorting.expected.jpg
deleted file mode 100644
index bd9d441cd..000000000
Binary files a/API.Tests/Services/Test Data/ArchiveService/CoverImages/sorting.expected.jpg and /dev/null differ
diff --git a/API.Tests/Services/Test Data/ArchiveService/CoverImages/sorting.expected.png b/API.Tests/Services/Test Data/ArchiveService/CoverImages/sorting.expected.png
new file mode 100644
index 000000000..665af5761
Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/CoverImages/sorting.expected.png differ
diff --git a/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - duplicate covers.expected.jpg b/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - duplicate covers.expected.jpg
deleted file mode 100644
index 51fd89ca0..000000000
Binary files a/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - duplicate covers.expected.jpg and /dev/null differ
diff --git a/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - duplicate covers.expected.png b/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - duplicate covers.expected.png
new file mode 100644
index 000000000..68bf77f0e
Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - duplicate covers.expected.png differ
diff --git a/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - nested folder.expected.jpg b/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - nested folder.expected.jpg
deleted file mode 100644
index 51fd89ca0..000000000
Binary files a/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - nested folder.expected.jpg and /dev/null differ
diff --git a/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - nested folder.expected.png b/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - nested folder.expected.png
new file mode 100644
index 000000000..68bf77f0e
Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - nested folder.expected.png differ
diff --git a/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - with folder.expected.png b/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - with folder.expected.png
new file mode 100644
index 000000000..9a6ada78e
Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10 - with folder.expected.png differ
diff --git a/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10.expected.jpg b/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10.expected.jpg
deleted file mode 100644
index 51fd89ca0..000000000
Binary files a/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10.expected.jpg and /dev/null differ
diff --git a/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10.expected.png b/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10.expected.png
new file mode 100644
index 000000000..68bf77f0e
Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/CoverImages/v10.expected.png differ
diff --git a/API/API.csproj b/API/API.csproj
index 42e0d1107..764ed1c2c 100644
--- a/API/API.csproj
+++ b/API/API.csproj
@@ -40,38 +40,40 @@
-
-
-
-
+
+
+
+
-
+
-
-
-
+
+
+
+
+
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
-
+
-
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
@@ -310,4 +312,8 @@
+
+
+
+
diff --git a/API/Comparators/ChapterSortComparer.cs b/API/Comparators/ChapterSortComparer.cs
index 189919a33..0e7f61a61 100644
--- a/API/Comparators/ChapterSortComparer.cs
+++ b/API/Comparators/ChapterSortComparer.cs
@@ -45,4 +45,18 @@ namespace API.Comparators
return x.CompareTo(y);
}
}
+
+ public class SortComparerZeroLast : IComparer
+ {
+ public int Compare(double x, double y)
+ {
+ if (x == 0.0 && y == 0.0) return 0;
+ // if x is 0, it comes last
+ if (x == 0.0) return 1;
+ // if y is 0, it comes last
+ if (y == 0.0) return -1;
+
+ return x.CompareTo(y);
+ }
+ }
}
diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs
index 9765f700e..986f69d06 100644
--- a/API/Controllers/AccountController.cs
+++ b/API/Controllers/AccountController.cs
@@ -106,7 +106,10 @@ namespace API.Controllers
{
UserName = registerDto.Username,
Email = registerDto.Email,
- UserPreferences = new AppUserPreferences(),
+ UserPreferences = new AppUserPreferences
+ {
+ Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme()
+ },
ApiKey = HashUtil.ApiKey()
};
@@ -179,22 +182,23 @@ namespace API.Controllers
// Update LastActive on account
user.LastActive = DateTime.Now;
- user.UserPreferences ??= new AppUserPreferences();
+ user.UserPreferences ??= new AppUserPreferences
+ {
+ Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme()
+ };
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
_logger.LogInformation("{UserName} logged in at {Time}", user.UserName, user.LastActive);
- return new UserDto
- {
- Username = user.UserName,
- Email = user.Email,
- Token = await _tokenService.CreateToken(user),
- RefreshToken = await _tokenService.CreateRefreshToken(user),
- ApiKey = user.ApiKey,
- Preferences = _mapper.Map(user.UserPreferences)
- };
+ var dto = _mapper.Map(user);
+ dto.Token = await _tokenService.CreateToken(user);
+ dto.RefreshToken = await _tokenService.CreateRefreshToken(user);
+ var pref = await _unitOfWork.UserRepository.GetPreferencesAsync(user.UserName);
+ pref.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
+ dto.Preferences = _mapper.Map(pref);
+ return dto;
}
[HttpPost("refresh-token")]
@@ -334,6 +338,12 @@ namespace API.Controllers
+ ///
+ /// Invites a user to the server. Will generate a setup link for continuing setup. If the server is not accessible, no
+ /// email will be sent.
+ ///
+ ///
+ ///
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("invite")]
public async Task> InviteUser(InviteUserDto dto)
@@ -358,7 +368,10 @@ namespace API.Controllers
UserName = dto.Email,
Email = dto.Email,
ApiKey = HashUtil.ApiKey(),
- UserPreferences = new AppUserPreferences()
+ UserPreferences = new AppUserPreferences
+ {
+ Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme()
+ }
};
try
@@ -410,7 +423,10 @@ namespace API.Controllers
var emailLink = GenerateEmailLink(token, "confirm-email", dto.Email);
_logger.LogCritical("[Invite User]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
- if (dto.SendEmail)
+
+ var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
+ var accessible = await _emailService.CheckIfAccessible(host);
+ if (accessible)
{
await _emailService.SendConfirmationEmail(new ConfirmationEmailDto()
{
@@ -419,7 +435,11 @@ namespace API.Controllers
ServerConfirmationLink = emailLink
});
}
- return Ok(emailLink);
+ return Ok(new InviteUserResponse
+ {
+ EmailLink = emailLink,
+ EmailSent = accessible
+ });
}
catch (Exception)
{
@@ -506,6 +526,12 @@ namespace API.Controllers
return Ok("An email will be sent to the email if it exists in our database");
}
+ var roles = await _userManager.GetRolesAsync(user);
+
+
+ if (!roles.Any(r => r is PolicyConstants.AdminRole or PolicyConstants.ChangePasswordRole))
+ return Unauthorized("You are not permitted to this operation.");
+
var emailLink = GenerateEmailLink(await _userManager.GeneratePasswordResetTokenAsync(user), "confirm-reset-password", user.Email);
_logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs
index 89921d5f2..6abc22955 100644
--- a/API/Controllers/CollectionController.cs
+++ b/API/Controllers/CollectionController.cs
@@ -19,13 +19,13 @@ namespace API.Controllers
public class CollectionController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
- private readonly IHubContext _messageHub;
+ private readonly IEventHub _eventHub;
///
- public CollectionController(IUnitOfWork unitOfWork, IHubContext messageHub)
+ public CollectionController(IUnitOfWork unitOfWork, IEventHub eventHub)
{
_unitOfWork = unitOfWork;
- _messageHub = messageHub;
+ _eventHub = eventHub;
}
///
@@ -156,7 +156,8 @@ namespace API.Controllers
{
tag.CoverImageLocked = false;
tag.CoverImage = string.Empty;
- await _messageHub.Clients.All.SendAsync(SignalREvents.CoverUpdate, MessageFactory.CoverUpdateEvent(tag.Id, "collectionTag"));
+ await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
+ MessageFactory.CoverUpdateEvent(tag.Id, "collectionTag"), false);
_unitOfWork.CollectionTagRepository.Update(tag);
}
diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs
index bb84138b2..169c34bd9 100644
--- a/API/Controllers/DownloadController.cs
+++ b/API/Controllers/DownloadController.cs
@@ -7,7 +7,6 @@ using API.Constants;
using API.Data;
using API.DTOs.Downloads;
using API.Entities;
-using API.Entities.Enums;
using API.Extensions;
using API.Services;
using API.SignalR;
@@ -15,7 +14,6 @@ using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace API.Controllers
@@ -27,21 +25,23 @@ namespace API.Controllers
private readonly IArchiveService _archiveService;
private readonly IDirectoryService _directoryService;
private readonly IDownloadService _downloadService;
- private readonly IHubContext _messageHub;
+ private readonly IEventHub _eventHub;
private readonly UserManager _userManager;
private readonly ILogger _logger;
+ private readonly IBookmarkService _bookmarkService;
private const string DefaultContentType = "application/octet-stream";
public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService,
- IDownloadService downloadService, IHubContext messageHub, UserManager userManager, ILogger logger)
+ IDownloadService downloadService, IEventHub eventHub, UserManager userManager, ILogger logger, IBookmarkService bookmarkService)
{
_unitOfWork = unitOfWork;
_archiveService = archiveService;
_directoryService = directoryService;
_downloadService = downloadService;
- _messageHub = messageHub;
+ _eventHub = eventHub;
_userManager = userManager;
_logger = logger;
+ _bookmarkService = bookmarkService;
}
[HttpGet("volume-size")]
@@ -65,6 +65,8 @@ namespace API.Controllers
return Ok(_directoryService.GetTotalSize(files.Select(c => c.FilePath)));
}
+
+
[Authorize(Policy="RequireDownloadRole")]
[HttpGet("volume")]
public async Task DownloadVolume(int volumeId)
@@ -87,8 +89,7 @@ namespace API.Controllers
private async Task HasDownloadPermission()
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
- var roles = await _userManager.GetRolesAsync(user);
- return roles.Contains(PolicyConstants.DownloadRole) || roles.Contains(PolicyConstants.AdminRole);
+ return await _downloadService.HasDownloadPermission(user);
}
private async Task GetFirstFileDownload(IEnumerable files)
@@ -119,30 +120,30 @@ namespace API.Controllers
{
try
{
- await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
+ await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.DownloadProgressEvent(User.GetUsername(),
- Path.GetFileNameWithoutExtension(downloadName), 0F));
+ Path.GetFileNameWithoutExtension(downloadName), 0F, "started"));
if (files.Count == 1)
{
- await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
+ await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.DownloadProgressEvent(User.GetUsername(),
- Path.GetFileNameWithoutExtension(downloadName), 1F));
+ Path.GetFileNameWithoutExtension(downloadName), 1F, "ended"));
return await GetFirstFileDownload(files);
}
var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
tempFolder);
- await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
+ await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.DownloadProgressEvent(User.GetUsername(),
- Path.GetFileNameWithoutExtension(downloadName), 1F));
+ Path.GetFileNameWithoutExtension(downloadName), 1F, "ended"));
return File(fileBytes, DefaultContentType, downloadName);
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception when trying to download files");
- await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
+ await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.DownloadProgressEvent(User.GetUsername(),
- Path.GetFileNameWithoutExtension(downloadName), 1F));
+ Path.GetFileNameWithoutExtension(downloadName), 1F, "ended"));
throw;
}
}
@@ -172,21 +173,17 @@ namespace API.Controllers
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(downloadBookmarkDto.Bookmarks.First().SeriesId);
- var bookmarkDirectory =
- (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
-
- var files = (await _unitOfWork.UserRepository.GetAllBookmarksByIds(downloadBookmarkDto.Bookmarks
- .Select(b => b.Id)
- .ToList()))
- .Select(b => Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(bookmarkDirectory, $"{b.ChapterId}_{b.FileName}")));
+ var files = await _bookmarkService.GetBookmarkFilesById(user.Id, downloadBookmarkDto.Bookmarks.Select(b => b.Id));
var filename = $"{series.Name} - Bookmarks.zip";
- await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
+ await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 0F));
var (fileBytes, _) = await _archiveService.CreateZipForDownload(files,
$"download_{user.Id}_{series.Id}_bookmarks");
- await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress,
+ await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 1F));
+
+
return File(fileBytes, DefaultContentType, filename);
}
diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs
index a875be14e..8b58fe9b3 100644
--- a/API/Controllers/ImageController.cs
+++ b/API/Controllers/ImageController.cs
@@ -4,6 +4,7 @@ using API.Data;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers
@@ -106,7 +107,26 @@ namespace API.Controllers
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
var file = new FileInfo(Path.Join(bookmarkDirectory, bookmark.FileName));
var format = Path.GetExtension(file.FullName).Replace(".", "");
+
+ Response.AddCacheHeader(file.FullName);
return PhysicalFile(file.FullName, "image/" + format, Path.GetFileName(file.FullName));
}
+
+ ///
+ /// Returns a temp coverupload image
+ ///
+ /// Filename of file. This is used with upload/upload-by-url
+ ///
+ [AllowAnonymous]
+ [HttpGet("cover-upload")]
+ public ActionResult GetCoverUploadImage(string filename)
+ {
+ var path = Path.Join(_directoryService.TempDirectory, filename);
+ if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"File does not exist");
+ var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", "");
+
+ Response.AddCacheHeader(path);
+ return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
+ }
}
}
diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs
index 3084ab352..85880a38d 100644
--- a/API/Controllers/LibraryController.cs
+++ b/API/Controllers/LibraryController.cs
@@ -11,6 +11,7 @@ using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
+using API.SignalR;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -26,16 +27,18 @@ namespace API.Controllers
private readonly IMapper _mapper;
private readonly ITaskScheduler _taskScheduler;
private readonly IUnitOfWork _unitOfWork;
+ private readonly IEventHub _eventHub;
public LibraryController(IDirectoryService directoryService,
ILogger logger, IMapper mapper, ITaskScheduler taskScheduler,
- IUnitOfWork unitOfWork)
+ IUnitOfWork unitOfWork, IEventHub eventHub)
{
_directoryService = directoryService;
_logger = logger;
_mapper = mapper;
_taskScheduler = taskScheduler;
_unitOfWork = unitOfWork;
+ _eventHub = eventHub;
}
///
@@ -73,6 +76,8 @@ namespace API.Controllers
_logger.LogInformation("Created a new library: {LibraryName}", library.Name);
_taskScheduler.ScanLibrary(library.Id);
+ await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
+ MessageFactory.LibraryModifiedEvent(library.Id, "create"), false);
return Ok();
}
@@ -191,6 +196,15 @@ namespace API.Controllers
await _unitOfWork.CommitAsync();
_taskScheduler.CleanupChapters(chapterIds);
}
+
+ foreach (var seriesId in seriesIds)
+ {
+ await _eventHub.SendMessageAsync(MessageFactory.SeriesRemoved,
+ MessageFactory.SeriesRemovedEvent(seriesId, string.Empty, libraryId), false);
+ }
+
+ await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
+ MessageFactory.LibraryModifiedEvent(libraryId, "delete"), false);
return Ok(true);
}
catch (Exception ex)
diff --git a/API/Controllers/MetadataController.cs b/API/Controllers/MetadataController.cs
index d3e0806ed..39c7264d2 100644
--- a/API/Controllers/MetadataController.cs
+++ b/API/Controllers/MetadataController.cs
@@ -123,18 +123,30 @@ public class MetadataController : BaseApiController
public async Task>> GetAllLanguages(string? libraryIds)
{
var ids = libraryIds?.Split(",").Select(int.Parse).ToList();
- if (ids != null && ids.Count > 0)
+ if (ids is {Count: > 0})
{
return Ok(await _unitOfWork.SeriesRepository.GetAllLanguagesForLibrariesAsync(ids));
}
+ var englishTag = CultureInfo.GetCultureInfo("en");
return Ok(new List()
{
new ()
{
- Title = CultureInfo.GetCultureInfo("en").DisplayName,
- IsoCode = "en"
+ Title = englishTag.DisplayName,
+ IsoCode = englishTag.IetfLanguageTag
}
});
}
+
+ [HttpGet("all-languages")]
+ public IEnumerable GetAllValidLanguages()
+ {
+ return CultureInfo.GetCultures(CultureTypes.AllCultures).Select(c =>
+ new LanguageDto()
+ {
+ Title = c.DisplayName,
+ IsoCode = c.IetfLanguageTag
+ }).Where(l => !string.IsNullOrEmpty(l.IsoCode));
+ }
}
diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs
index 21e184e33..91607db7d 100644
--- a/API/Controllers/OPDSController.cs
+++ b/API/Controllers/OPDSController.cs
@@ -28,6 +28,7 @@ public class OpdsController : BaseApiController
private readonly IDirectoryService _directoryService;
private readonly ICacheService _cacheService;
private readonly IReaderService _readerService;
+ private readonly ISeriesService _seriesService;
private readonly XmlSerializer _xmlSerializer;
@@ -61,13 +62,14 @@ public class OpdsController : BaseApiController
public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService,
IDirectoryService directoryService, ICacheService cacheService,
- IReaderService readerService)
+ IReaderService readerService, ISeriesService seriesService)
{
_unitOfWork = unitOfWork;
_downloadService = downloadService;
_directoryService = directoryService;
_cacheService = cacheService;
_readerService = readerService;
+ _seriesService = seriesService;
_xmlSerializer = new XmlSerializer(typeof(Feed));
_xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription));
@@ -314,18 +316,8 @@ public class OpdsController : BaseApiController
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList();
foreach (var item in items)
{
- feed.Entries.Add(new FeedEntry()
- {
- Id = item.ChapterId.ToString(),
- Title = $"{item.SeriesName} Chapter {item.ChapterNumber}",
- Links = new List()
- {
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{item.SeriesId}/volume/{item.VolumeId}/chapter/{item.ChapterId}"),
- CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={item.ChapterId}")
- }
- });
+ feed.Entries.Add(CreateChapter(apiKey, $"{item.SeriesName} Chapter {item.ChapterNumber}", item.ChapterId, item.VolumeId, item.SeriesId));
}
-
return CreateXmlResult(SerializeXml(feed));
}
@@ -499,7 +491,7 @@ public class OpdsController : BaseApiController
var feed = new OpenSearchDescription()
{
ShortName = "Search",
- Description = "Search for Series",
+ Description = "Search for Series, Collections, or Reading Lists",
Url = new SearchLink()
{
Type = FeedLinkType.AtomAcquisition,
@@ -521,13 +513,37 @@ public class OpdsController : BaseApiController
return BadRequest("OPDS is not enabled on this server");
var userId = await GetUser(apiKey);
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
- var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId);
- var feed = CreateFeed(series.Name + " - Volumes", $"{apiKey}/series/{series.Id}", apiKey);
+
+ var feed = CreateFeed(series.Name + " - Storyline", $"{apiKey}/series/{series.Id}", apiKey);
SetFeedId(feed, $"series-{series.Id}");
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesId}"));
- foreach (var volumeDto in volumes)
+
+ var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
+ foreach (var volume in seriesDetail.Volumes)
{
- feed.Entries.Add(CreateVolume(volumeDto, seriesId, apiKey));
+ // If there is only one chapter to the Volume, we will emulate a volume to flatten the amount of hops a user must go through
+ if (volume.Chapters.Count == 1)
+ {
+ var firstChapter = volume.Chapters.First();
+ var chapter = CreateChapter(apiKey, volume.Name, firstChapter.Id, volume.Id, seriesId);
+ chapter.Id = firstChapter.Id.ToString();
+ feed.Entries.Add(chapter);
+ }
+ else
+ {
+ feed.Entries.Add(CreateVolume(volume, seriesId, apiKey));
+ }
+
+ }
+
+ foreach (var storylineChapter in seriesDetail.StorylineChapters.Where(c => !c.IsSpecial))
+ {
+ feed.Entries.Add(CreateChapter(apiKey, storylineChapter.Title, storylineChapter.Id, storylineChapter.VolumeId, seriesId));
+ }
+
+ foreach (var special in seriesDetail.Specials)
+ {
+ feed.Entries.Add(CreateChapter(apiKey, special.Title, special.Id, special.VolumeId, seriesId));
}
return CreateXmlResult(SerializeXml(feed));
@@ -541,19 +557,20 @@ public class OpdsController : BaseApiController
return BadRequest("OPDS is not enabled on this server");
var userId = await GetUser(apiKey);
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
+ var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
var chapters =
(await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number),
_chapterSortComparer);
- var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey);
- SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-chapters");
+ var feed = CreateFeed(series.Name + " - Volume " + volume.Name + $" - {SeriesService.FormatChapterName(libraryType)}s ", $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey);
+ SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{SeriesService.FormatChapterName(libraryType)}s");
foreach (var chapter in chapters)
{
feed.Entries.Add(new FeedEntry()
{
Id = chapter.Id.ToString(),
- Title = "Chapter " + chapter.Number,
+ Title = SeriesService.FormatChapterTitle(chapter, libraryType),
Links = new List()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapter.Id}"),
@@ -573,15 +590,16 @@ public class OpdsController : BaseApiController
return BadRequest("OPDS is not enabled on this server");
var userId = await GetUser(apiKey);
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
+ var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
- var feed = CreateFeed(series.Name + " - Volume " + volume.Name + " - Chapters ", $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey);
- SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-chapter-{chapter.Id}-files");
+ var feed = CreateFeed(series.Name + " - Volume " + volume.Name + $" - {SeriesService.FormatChapterName(libraryType)}s", $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey);
+ SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{SeriesService.FormatChapterName(libraryType)}-{chapterId}-files");
foreach (var mangaFile in files)
{
- feed.Entries.Add(CreateChapter(seriesId, volumeId, chapterId, mangaFile, series, volume, chapter, apiKey));
+ feed.Entries.Add(await CreateChapterWithFile(seriesId, volumeId, chapterId, mangaFile, series, chapter, apiKey));
}
return CreateXmlResult(SerializeXml(feed));
@@ -600,6 +618,12 @@ public class OpdsController : BaseApiController
{
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest("OPDS is not enabled on this server");
+ var user = await _unitOfWork.UserRepository.GetUserByIdAsync(await GetUser(apiKey));
+ if (!await _downloadService.HasDownloadPermission(user))
+ {
+ return BadRequest("User does not have download permissions");
+ }
+
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
var (bytes, contentType, fileDownloadName) = await _downloadService.GetFirstFileDownload(files);
return File(bytes, contentType, fileDownloadName);
@@ -688,27 +712,69 @@ public class OpdsController : BaseApiController
return new FeedEntry()
{
Id = volumeDto.Id.ToString(),
- Title = "Volume " + volumeDto.Name,
+ Title = volumeDto.Name,
Links = new List()
{
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeDto.Id}"),
- CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/volume-cover?volumeId={volumeDto.Id}"),
- CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/volume-cover?volumeId={volumeDto.Id}")
+ CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
+ Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeDto.Id}"),
+ CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
+ $"/api/image/volume-cover?volumeId={volumeDto.Id}"),
+ CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
+ $"/api/image/volume-cover?volumeId={volumeDto.Id}")
}
};
}
- private FeedEntry CreateChapter(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, Volume volume, ChapterDto chapter, string apiKey)
+ private static FeedEntry CreateChapter(string apiKey, string title, int chapterId, int volumeId, int seriesId)
+ {
+ return new FeedEntry()
+ {
+ Id = chapterId.ToString(),
+ Title = title,
+ Links = new List()
+ {
+ CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
+ Prefix + $"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}"),
+ CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
+ $"/api/image/chapter-cover?chapterId={chapterId}"),
+ CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
+ $"/api/image/chapter-cover?chapterId={chapterId}")
+ }
+ };
+ }
+
+ private async Task CreateChapterWithFile(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, SeriesDto series, ChapterDto chapter, string apiKey)
{
var fileSize =
DirectoryService.GetHumanReadableBytes(_directoryService.GetTotalSize(new List()
{mangaFile.FilePath}));
var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath);
var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath) ?? string.Empty);
- return new FeedEntry()
+ var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
+ var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, await GetUser(apiKey));
+
+ var title = $"{series.Name} - ";
+ if (volume.Chapters.Count == 1)
+ {
+ SeriesService.RenameVolumeName(volume.Chapters.First(), volume, libraryType);
+ title += $"{volume.Name}";
+ }
+ else
+ {
+ title = $"{series.Name} - {SeriesService.FormatChapterTitle(chapter, libraryType)}";
+ }
+
+ // Chunky requires a file at the end. Our API ignores this
+ var accLink =
+ CreateLink(FeedLinkRelation.Acquisition, fileType,
+ $"{Prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}",
+ filename);
+ accLink.TotalPages = chapter.Pages;
+
+ var entry = new FeedEntry()
{
Id = mangaFile.Id.ToString(),
- Title = $"{series.Name} - Volume {volume.Name} - Chapter {chapter.Number}",
+ Title = title,
Extent = fileSize,
Summary = $"{fileType.Split("/")[1]} - {fileSize}",
Format = mangaFile.Format.ToString(),
@@ -716,8 +782,7 @@ public class OpdsController : BaseApiController
{
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"),
- // Chunky requires a file at the end. Our API ignores this
- CreateLink(FeedLinkRelation.Acquisition, fileType, $"{Prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}"),
+ accLink,
CreatePageStreamLink(seriesId, volumeId, chapterId, mangaFile, apiKey)
},
Content = new FeedEntryContent()
@@ -726,6 +791,16 @@ public class OpdsController : BaseApiController
Type = "text"
}
};
+
+ // We can't not show acc link in the feed, panels wont work like that. We have to block download directly
+ // var user = await _unitOfWork.UserRepository.GetUserByIdAsync(await GetUser(apiKey));
+ // if (await _downloadService.HasDownloadPermission(user))
+ // {
+ // entry.Links.Add(accLink);
+ // }
+
+
+ return entry;
}
[HttpGet("{apiKey}/image")]
@@ -804,13 +879,14 @@ public class OpdsController : BaseApiController
return link;
}
- private static FeedLink CreateLink(string rel, string type, string href)
+ private static FeedLink CreateLink(string rel, string type, string href, string title = null)
{
return new FeedLink()
{
Rel = rel,
Href = href,
- Type = type
+ Type = type,
+ Title = string.IsNullOrEmpty(title) ? string.Empty : title
};
}
diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs
index a71d73e42..e2d067abd 100644
--- a/API/Controllers/ReaderController.cs
+++ b/API/Controllers/ReaderController.cs
@@ -63,6 +63,7 @@ namespace API.Controllers
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}");
var format = Path.GetExtension(path).Replace(".", "");
+ Response.AddCacheHeader(path, TimeSpan.FromMinutes(10).Seconds);
return PhysicalFile(path, "image/" + format, Path.GetFileName(path));
}
catch (Exception)
@@ -108,14 +109,7 @@ namespace API.Controllers
public async Task MarkRead(MarkReadDto markReadDto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
- var volumes = await _unitOfWork.VolumeRepository.GetVolumes(markReadDto.SeriesId);
- user.Progresses ??= new List();
- foreach (var volume in volumes)
- {
- _readerService.MarkChaptersAsRead(user, markReadDto.SeriesId, volume.Chapters);
- }
-
- _unitOfWork.UserRepository.Update(user);
+ await _readerService.MarkSeriesAsRead(user, markReadDto.SeriesId);
if (await _unitOfWork.CommitAsync())
{
@@ -136,14 +130,7 @@ namespace API.Controllers
public async Task MarkUnread(MarkReadDto markReadDto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
- var volumes = await _unitOfWork.VolumeRepository.GetVolumes(markReadDto.SeriesId);
- user.Progresses ??= new List();
- foreach (var volume in volumes)
- {
- _readerService.MarkChaptersAsUnread(user, markReadDto.SeriesId, volume.Chapters);
- }
-
- _unitOfWork.UserRepository.Update(user);
+ await _readerService.MarkSeriesAsUnread(user, markReadDto.SeriesId);
if (await _unitOfWork.CommitAsync())
{
@@ -393,6 +380,10 @@ namespace API.Controllers
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
user.Progresses ??= new List();
+ // Tachiyomi sends chapter 0.0f when there's no chapters read.
+ // Due to the encoding for volumes this marks all chapters in volume 0 (loose chapters) as read so we ignore it
+ if (chapterNumber == 0.0f) return true;
+
if (chapterNumber < 1.0f)
{
// This is a hack to track volume number. We need to map it back by x100
diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs
index 34a7e47b8..83cdc1a04 100644
--- a/API/Controllers/ReadingListController.cs
+++ b/API/Controllers/ReadingListController.cs
@@ -49,6 +49,15 @@ namespace API.Controllers
return Ok(items);
}
+ [HttpGet("lists-for-series")]
+ public async Task>> GetListsForSeries(int seriesId)
+ {
+ var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
+ var items = await _unitOfWork.ReadingListRepository.GetReadingListDtosForSeriesAndUserAsync(userId, seriesId, true);
+
+ return Ok(items);
+ }
+
///
/// Fetches all reading list items for a given list including rich metadata around series, volume, chapters, and progress
///
diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs
index 397109c09..28751dbc1 100644
--- a/API/Controllers/SeriesController.cs
+++ b/API/Controllers/SeriesController.cs
@@ -11,12 +11,10 @@ using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Services;
-using API.SignalR;
using Kavita.Common;
using Kavita.Common.Extensions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace API.Controllers
@@ -26,14 +24,15 @@ namespace API.Controllers
private readonly ILogger _logger;
private readonly ITaskScheduler _taskScheduler;
private readonly IUnitOfWork _unitOfWork;
- private readonly IHubContext _messageHub;
+ private readonly ISeriesService _seriesService;
- public SeriesController(ILogger logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, IHubContext messageHub)
+
+ public SeriesController(ILogger logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, ISeriesService seriesService)
{
_logger = logger;
_taskScheduler = taskScheduler;
_unitOfWork = unitOfWork;
- _messageHub = messageHub;
+ _seriesService = seriesService;
}
[HttpPost]
@@ -59,7 +58,7 @@ namespace API.Controllers
/// Series Id to fetch details for
///
/// Throws an exception if the series Id does exist
- [HttpGet("{seriesId}")]
+ [HttpGet("{seriesId:int}")]
public async Task> GetSeries(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
@@ -82,21 +81,7 @@ namespace API.Controllers
var username = User.GetUsername();
_logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", seriesId, username);
- var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
-
- var chapterIds = (await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new []{seriesId}));
- var result = await _unitOfWork.SeriesRepository.DeleteSeriesAsync(seriesId);
-
- if (result)
- {
- await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
- await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
- await _unitOfWork.CommitAsync();
- _taskScheduler.CleanupChapters(chapterIds);
- await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesRemoved,
- MessageFactory.SeriesRemovedEvent(seriesId, series.Name, series.LibraryId));
- }
- return Ok(result);
+ return Ok(await _seriesService.DeleteMultipleSeries(new[] {seriesId}));
}
[Authorize(Policy = "RequireAdminRole")]
@@ -106,25 +91,9 @@ namespace API.Controllers
var username = User.GetUsername();
_logger.LogInformation("Series {SeriesId} is being deleted by {UserName}", dto.SeriesIds, username);
- var chapterMappings =
- await _unitOfWork.SeriesRepository.GetChapterIdWithSeriesIdForSeriesAsync(dto.SeriesIds.ToArray());
+ if (await _seriesService.DeleteMultipleSeries(dto.SeriesIds)) return Ok();
- var allChapterIds = new List();
- foreach (var mapping in chapterMappings)
- {
- allChapterIds.AddRange(mapping.Value);
- }
-
- var series = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(dto.SeriesIds);
- _unitOfWork.SeriesRepository.Remove(series);
-
- if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync())
- {
- await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
- await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
- _taskScheduler.CleanupChapters(allChapterIds.ToArray());
- }
- return Ok();
+ return BadRequest("There was an issue deleting the series requested");
}
///
@@ -152,28 +121,18 @@ namespace API.Controllers
return Ok(await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId));
}
+ [HttpGet("chapter-metadata")]
+ public async Task> GetChapterMetadata(int chapterId)
+ {
+ return Ok(await _unitOfWork.ChapterRepository.GetChapterMetadataDtoAsync(chapterId));
+ }
+
[HttpPost("update-rating")]
public async Task UpdateSeriesRating(UpdateSeriesRatingDto updateSeriesRatingDto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Ratings);
- var userRating = await _unitOfWork.UserRepository.GetUserRatingAsync(updateSeriesRatingDto.SeriesId, user.Id) ??
- new AppUserRating();
-
- userRating.Rating = updateSeriesRatingDto.UserRating;
- userRating.Review = updateSeriesRatingDto.UserReview;
- userRating.SeriesId = updateSeriesRatingDto.SeriesId;
-
- if (userRating.Id == 0)
- {
- user.Ratings ??= new List();
- user.Ratings.Add(userRating);
- }
-
- _unitOfWork.UserRepository.Update(user);
-
- if (!await _unitOfWork.CommitAsync()) return BadRequest("There was a critical error.");
-
+ if (!await _seriesService.UpdateRating(user, updateSeriesRatingDto)) return BadRequest("There was a critical error.");
return Ok();
}
@@ -190,10 +149,15 @@ namespace API.Controllers
{
return BadRequest("A series already exists in this library with this name. Series Names must be unique to a library.");
}
+
series.Name = updateSeries.Name.Trim();
+ series.SortName = updateSeries.SortName.Trim();
series.LocalizedName = updateSeries.LocalizedName.Trim();
- series.SortName = updateSeries.SortName?.Trim();
- series.Metadata.Summary = updateSeries.Summary?.Trim();
+
+ series.NameLocked = updateSeries.NameLocked;
+ series.SortNameLocked = updateSeries.SortNameLocked;
+ series.LocalizedNameLocked = updateSeries.LocalizedNameLocked;
+
var needsRefreshMetadata = false;
// This is when you hit Reset
@@ -281,8 +245,7 @@ namespace API.Controllers
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var results = await _unitOfWork.SeriesRepository.GetOnDeck(userId, libraryId, userParams, filterDto);
- var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize)
- .Take(userParams.PageSize).ToList();
+ var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize).Take(userParams.PageSize).ToList();
var pagedList = new PagedList(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize);
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, pagedList);
@@ -318,77 +281,9 @@ namespace API.Controllers
[HttpPost("metadata")]
public async Task UpdateSeriesMetadata(UpdateSeriesMetadataDto updateSeriesMetadataDto)
{
- try
+ if (await _seriesService.UpdateSeriesMetadata(updateSeriesMetadataDto))
{
- var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId;
- var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
- var allTags = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).ToList();
- if (series.Metadata == null)
- {
- series.Metadata = DbFactory.SeriesMetadata(updateSeriesMetadataDto.Tags
- .Select(dto => DbFactory.CollectionTag(dto.Id, dto.Title, dto.Summary, dto.Promoted)).ToList());
- }
- else
- {
- series.Metadata.CollectionTags ??= new List();
- // TODO: Move this merging logic into a reusable code as it can be used for any Tag
- var newTags = new List();
-
- // I want a union of these 2 lists. Return only elements that are in both lists, but the list types are different
- var existingTags = series.Metadata.CollectionTags.ToList();
- foreach (var existing in existingTags)
- {
- if (updateSeriesMetadataDto.Tags.SingleOrDefault(t => t.Id == existing.Id) == null)
- {
- // Remove tag
- series.Metadata.CollectionTags.Remove(existing);
- }
- }
-
- // At this point, all tags that aren't in dto have been removed.
- foreach (var tag in updateSeriesMetadataDto.Tags)
- {
- var existingTag = allTags.SingleOrDefault(t => t.Title == tag.Title);
- if (existingTag != null)
- {
- if (series.Metadata.CollectionTags.All(t => t.Title != tag.Title))
- {
- newTags.Add(existingTag);
- }
- }
- else
- {
- // Add new tag
- newTags.Add(DbFactory.CollectionTag(tag.Id, tag.Title, tag.Summary, tag.Promoted));
- }
- }
-
- foreach (var tag in newTags)
- {
- series.Metadata.CollectionTags.Add(tag);
- }
- }
-
- if (!_unitOfWork.HasChanges())
- {
- return Ok("No changes to save");
- }
-
- if (await _unitOfWork.CommitAsync())
- {
- foreach (var tag in updateSeriesMetadataDto.Tags)
- {
- await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesAddedToCollection,
- MessageFactory.SeriesAddedToCollection(tag.Id,
- updateSeriesMetadataDto.SeriesMetadata.SeriesId));
- }
- return Ok("Successfully updated");
- }
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "There was an exception when updating metadata");
- await _unitOfWork.RollbackAsync();
+ return Ok("Successfully updated");
}
return BadRequest("Could not update metadata");
@@ -437,5 +332,12 @@ namespace API.Controllers
return Ok(val.ToDescription());
}
+
+ [HttpGet("series-detail")]
+ public async Task> GetSeriesDetailBreakdown(int seriesId)
+ {
+ var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
+ return await _seriesService.GetSeriesDetail(seriesId, userId);
+ }
}
}
diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs
index 7f5c16b0b..222243fdc 100644
--- a/API/Controllers/ServerController.cs
+++ b/API/Controllers/ServerController.cs
@@ -7,6 +7,7 @@ using API.DTOs.Update;
using API.Extensions;
using API.Services;
using API.Services.Tasks;
+using Hangfire;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs
index d42b775b2..88207bf3c 100644
--- a/API/Controllers/SettingsController.cs
+++ b/API/Controllers/SettingsController.cs
@@ -105,16 +105,6 @@ namespace API.Controllers
{
_logger.LogInformation("{UserName} is updating Server Settings", User.GetUsername());
- if (updateSettingsDto.CacheDirectory.Equals(string.Empty))
- {
- return BadRequest("Cache Directory cannot be empty");
- }
-
- if (!Directory.Exists(updateSettingsDto.CacheDirectory))
- {
- return BadRequest("Directory does not exist or is not accessible.");
- }
-
// We do not allow CacheDirectory changes, so we will ignore.
var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync();
var updateBookmarks = false;
diff --git a/API/Controllers/ThemeController.cs b/API/Controllers/ThemeController.cs
new file mode 100644
index 000000000..f6775d2dc
--- /dev/null
+++ b/API/Controllers/ThemeController.cs
@@ -0,0 +1,64 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using API.Data;
+using API.DTOs.Theme;
+using API.Services;
+using API.Services.Tasks;
+using Kavita.Common;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace API.Controllers;
+
+public class ThemeController : BaseApiController
+{
+ private readonly IUnitOfWork _unitOfWork;
+ private readonly ISiteThemeService _siteThemeService;
+ private readonly ITaskScheduler _taskScheduler;
+
+ public ThemeController(IUnitOfWork unitOfWork, ISiteThemeService siteThemeService, ITaskScheduler taskScheduler)
+ {
+ _unitOfWork = unitOfWork;
+ _siteThemeService = siteThemeService;
+ _taskScheduler = taskScheduler;
+ }
+
+ [HttpGet]
+ public async Task>> GetThemes()
+ {
+ return Ok(await _unitOfWork.SiteThemeRepository.GetThemeDtos());
+ }
+
+ [Authorize("RequireAdminRole")]
+ [HttpPost("scan")]
+ public ActionResult Scan()
+ {
+ _taskScheduler.ScanSiteThemes();
+ return Ok();
+ }
+
+ [Authorize("RequireAdminRole")]
+ [HttpPost("update-default")]
+ public async Task UpdateDefault(UpdateDefaultSiteThemeDto dto)
+ {
+ await _siteThemeService.UpdateDefault(dto.ThemeId);
+ return Ok();
+ }
+
+ ///
+ /// Returns css content to the UI. UI is expected to escape the content
+ ///
+ ///
+ [HttpGet("download-content")]
+ public async Task> GetThemeContent(int themeId)
+ {
+ try
+ {
+ return Ok(await _siteThemeService.GetContent(themeId));
+ }
+ catch (KavitaException ex)
+ {
+ return BadRequest(ex.Message);
+ }
+ }
+}
diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs
index b383021e2..4d07d4225 100644
--- a/API/Controllers/UploadController.cs
+++ b/API/Controllers/UploadController.cs
@@ -1,8 +1,11 @@
using System;
+using System.IO;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Uploads;
+using API.Extensions;
using API.Services;
+using Flurl.Http;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@@ -20,14 +23,49 @@ namespace API.Controllers
private readonly IImageService _imageService;
private readonly ILogger _logger;
private readonly ITaskScheduler _taskScheduler;
+ private readonly IDirectoryService _directoryService;
///
- public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger logger, ITaskScheduler taskScheduler)
+ public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger logger,
+ ITaskScheduler taskScheduler, IDirectoryService directoryService)
{
_unitOfWork = unitOfWork;
_imageService = imageService;
_logger = logger;
_taskScheduler = taskScheduler;
+ _directoryService = directoryService;
+ }
+
+ ///
+ /// This stores a file (image) in temp directory for use in a cover image replacement flow.
+ /// This is automatically cleaned up.
+ ///
+ /// Escaped url to download from
+ /// filename
+ [Authorize(Policy = "RequireAdminRole")]
+ [HttpPost("upload-by-url")]
+ public async Task> GetImageFromFile(UploadUrlDto dto)
+ {
+ var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_");
+ var format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", "");
+ try
+ {
+ var path = await dto.Url
+ .DownloadFileAsync(_directoryService.TempDirectory, $"coverupload_{dateString}.{format}");
+
+ if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path))
+ return BadRequest($"Could not download file");
+
+ return $"coverupload_{dateString}.{format}";
+ }
+ catch (FlurlHttpException ex)
+ {
+ // Unauthorized
+ if (ex.StatusCode == 401)
+ return BadRequest("The server requires authentication to load the url externally");
+ }
+
+ return BadRequest("Unable to download image, please use another url or upload by file");
}
///
diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs
index dd6e975ab..0a7eeb4d1 100644
--- a/API/Controllers/UsersController.cs
+++ b/API/Controllers/UsersController.cs
@@ -4,7 +4,9 @@ using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
+using API.Entities.Enums;
using API.Extensions;
+using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -14,10 +16,12 @@ namespace API.Controllers
public class UsersController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
+ private readonly IMapper _mapper;
- public UsersController(IUnitOfWork unitOfWork)
+ public UsersController(IUnitOfWork unitOfWork, IMapper mapper)
{
_unitOfWork = unitOfWork;
+ _mapper = mapper;
}
[Authorize(Policy = "RequireAdminRole")]
@@ -71,14 +75,21 @@ namespace API.Controllers
existingPreferences.ScalingOption = preferencesDto.ScalingOption;
existingPreferences.PageSplitOption = preferencesDto.PageSplitOption;
existingPreferences.AutoCloseMenu = preferencesDto.AutoCloseMenu;
+ existingPreferences.ShowScreenHints = preferencesDto.ShowScreenHints;
existingPreferences.ReaderMode = preferencesDto.ReaderMode;
+ existingPreferences.LayoutMode = preferencesDto.LayoutMode;
+ existingPreferences.BackgroundColor = string.IsNullOrEmpty(preferencesDto.BackgroundColor) ? "#000000" : preferencesDto.BackgroundColor;
existingPreferences.BookReaderMargin = preferencesDto.BookReaderMargin;
existingPreferences.BookReaderLineSpacing = preferencesDto.BookReaderLineSpacing;
existingPreferences.BookReaderFontFamily = preferencesDto.BookReaderFontFamily;
existingPreferences.BookReaderDarkMode = preferencesDto.BookReaderDarkMode;
existingPreferences.BookReaderFontSize = preferencesDto.BookReaderFontSize;
existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate;
- existingPreferences.SiteDarkMode = preferencesDto.SiteDarkMode;
+ existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection;
+ existingPreferences.Theme = await _unitOfWork.SiteThemeRepository.GetThemeById(preferencesDto.Theme.Id);
+
+ // TODO: Remove this code - this overrides layout mode to be single until the mode is released
+ existingPreferences.LayoutMode = LayoutMode.Single;
_unitOfWork.UserRepository.Update(existingPreferences);
@@ -89,5 +100,13 @@ namespace API.Controllers
return BadRequest("There was an issue saving preferences.");
}
+
+ [HttpGet("get-preferences")]
+ public async Task> GetPreferences()
+ {
+ return _mapper.Map(
+ await _unitOfWork.UserRepository.GetPreferencesAsync(User.GetUsername()));
+
+ }
}
}
diff --git a/API/DTOs/Account/InviteUserDto.cs b/API/DTOs/Account/InviteUserDto.cs
index 04c9c1103..42d4bdf8e 100644
--- a/API/DTOs/Account/InviteUserDto.cs
+++ b/API/DTOs/Account/InviteUserDto.cs
@@ -16,6 +16,4 @@ public class InviteUserDto
/// A list of libraries to grant access to
///
public IList Libraries { get; init; }
-
- public bool SendEmail { get; init; } = true;
}
diff --git a/API/DTOs/Account/InviteUserResponse.cs b/API/DTOs/Account/InviteUserResponse.cs
new file mode 100644
index 000000000..9387b5492
--- /dev/null
+++ b/API/DTOs/Account/InviteUserResponse.cs
@@ -0,0 +1,13 @@
+namespace API.DTOs.Account;
+
+public class InviteUserResponse
+{
+ ///
+ /// Email link used to setup the user account
+ ///
+ public string EmailLink { get; set; }
+ ///
+ /// Was an email sent (ie is this server accessible)
+ ///
+ public bool EmailSent { get; set; }
+}
diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs
index 10956b529..e22046b60 100644
--- a/API/DTOs/ChapterDto.cs
+++ b/API/DTOs/ChapterDto.cs
@@ -30,7 +30,7 @@ namespace API.DTOs
///
/// Used for books/specials to display custom title. For non-specials/books, will be set to
///
- public string Title { get; init; }
+ public string Title { get; set; }
///
/// The files that represent this Chapter
///
@@ -61,31 +61,5 @@ namespace API.DTOs
///
/// Metadata field
public string TitleName { get; set; }
- ///
- /// Summary for the Chapter/Issue
- ///
- public string Summary { get; set; }
- ///
- /// Language for the Chapter/Issue
- ///
- public string Language { get; set; }
- ///
- /// Number in the TotalCount of issues
- ///
- public int Count { get; set; }
- ///
- /// Total number of issues for the series
- ///
- public int TotalCount { get; set; }
- public ICollection Writers { get; set; } = new List();
- public ICollection Penciller { get; set; } = new List();
- public ICollection Inker { get; set; } = new List();
- public ICollection Colorist { get; set; } = new List();
- public ICollection Letterer { get; set; } = new List();
- public ICollection CoverArtist { get; set; } = new List();
- public ICollection Editor { get; set; } = new List();
- public ICollection Publisher { get; set; } = new List();
- public ICollection Translators { get; set; } = new List();
- public ICollection Tags { get; set; } = new List();
}
}
diff --git a/API/DTOs/Filtering/FilterDto.cs b/API/DTOs/Filtering/FilterDto.cs
index fba9a7493..1a8d9fc8b 100644
--- a/API/DTOs/Filtering/FilterDto.cs
+++ b/API/DTOs/Filtering/FilterDto.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Runtime.InteropServices;
using API.Entities;
using API.Entities.Enums;
@@ -25,51 +26,51 @@ namespace API.DTOs.Filtering
///
public IList Genres { get; init; } = new List();
///
- /// A list of Writers to restrict search to. Defaults to all genres by passing an empty list
+ /// A list of Writers to restrict search to. Defaults to all Writers by passing an empty list
///
public IList Writers { get; init; } = new List();
///
- /// A list of Penciller ids to restrict search to. Defaults to all genres by passing an empty list
+ /// A list of Penciller ids to restrict search to. Defaults to all Pencillers by passing an empty list
///
public IList Penciller { get; init; } = new List();
///
- /// A list of Inker ids to restrict search to. Defaults to all genres by passing an empty list
+ /// A list of Inker ids to restrict search to. Defaults to all Inkers by passing an empty list
///
public IList Inker { get; init; } = new List();
///
- /// A list of Colorist ids to restrict search to. Defaults to all genres by passing an empty list
+ /// A list of Colorist ids to restrict search to. Defaults to all Colorists by passing an empty list
///
public IList Colorist { get; init; } = new List();
///
- /// A list of Letterer ids to restrict search to. Defaults to all genres by passing an empty list
+ /// A list of Letterer ids to restrict search to. Defaults to all Letterers by passing an empty list
///
public IList Letterer { get; init; } = new List();
///
- /// A list of CoverArtist ids to restrict search to. Defaults to all genres by passing an empty list
+ /// A list of CoverArtist ids to restrict search to. Defaults to all CoverArtists by passing an empty list
///
public IList CoverArtist { get; init; } = new List();
///
- /// A list of Editor ids to restrict search to. Defaults to all genres by passing an empty list
+ /// A list of Editor ids to restrict search to. Defaults to all Editors by passing an empty list
///
public IList Editor { get; init; } = new List();
///
- /// A list of Publisher ids to restrict search to. Defaults to all genres by passing an empty list
+ /// A list of Publisher ids to restrict search to. Defaults to all Publishers by passing an empty list
///
public IList Publisher { get; init; } = new List();
///
- /// A list of Character ids to restrict search to. Defaults to all genres by passing an empty list
+ /// A list of Character ids to restrict search to. Defaults to all Characters by passing an empty list
///
public IList Character { get; init; } = new List();
///
- /// A list of Translator ids to restrict search to. Defaults to all genres by passing an empty list
+ /// A list of Translator ids to restrict search to. Defaults to all Translatorss by passing an empty list
///
public IList Translators { get; init; } = new List();
///
- /// A list of Collection Tag ids to restrict search to. Defaults to all genres by passing an empty list
+ /// A list of Collection Tag ids to restrict search to. Defaults to all Collection Tags by passing an empty list
///
public IList CollectionTags { get; init; } = new List();
///
- /// A list of Tag ids to restrict search to. Defaults to all genres by passing an empty list
+ /// A list of Tag ids to restrict search to. Defaults to all Tags by passing an empty list
///
public IList Tags { get; init; } = new List();
///
@@ -94,5 +95,10 @@ namespace API.DTOs.Filtering
///
public IList PublicationStatus { get; init; } = new List();
+ ///
+ /// An optional name string to filter by. Empty string will ignore.
+ ///
+ public string SeriesNameQuery { get; init; } = string.Empty;
+
}
}
diff --git a/API/DTOs/Metadata/ChapterMetadataDto.cs b/API/DTOs/Metadata/ChapterMetadataDto.cs
index 77b89fe94..7b81fb099 100644
--- a/API/DTOs/Metadata/ChapterMetadataDto.cs
+++ b/API/DTOs/Metadata/ChapterMetadataDto.cs
@@ -1,19 +1,52 @@
using System.Collections.Generic;
+using API.Entities.Enums;
namespace API.DTOs.Metadata
{
+ ///
+ /// Exclusively metadata about a given chapter
+ ///
public class ChapterMetadataDto
{
public int Id { get; set; }
+ public int ChapterId { get; set; }
public string Title { get; set; }
public ICollection Writers { get; set; } = new List();
- public ICollection Penciller { get; set; } = new List();
- public ICollection Inker { get; set; } = new List();
- public ICollection Colorist { get; set; } = new List();
- public ICollection Letterer { get; set; } = new List();
- public ICollection CoverArtist { get; set; } = new List();
- public ICollection Editor { get; set; } = new List();
- public ICollection Publisher { get; set; } = new List();
- public int ChapterId { get; set; }
+ public ICollection CoverArtists { get; set; } = new List();
+ public ICollection Publishers { get; set; } = new List();
+ public ICollection Characters { get; set; } = new List();
+ public ICollection Pencillers { get; set; } = new List();
+ public ICollection Inkers { get; set; } = new List();
+ public ICollection Colorists { get; set; } = new List();
+ public ICollection Letterers { get; set; } = new List();
+ public ICollection Editors { get; set; } = new List();
+ public ICollection Translators { get; set; } = new List();
+
+ public ICollection Genres { get; set; } = new List();
+
+ ///
+ /// Collection of all Tags from underlying chapters for a Series
+ ///
+ public ICollection Tags { get; set; } = new List();
+ public AgeRating AgeRating { get; set; }
+ public string ReleaseDate { get; set; }
+ public PublicationStatus PublicationStatus { get; set; }
+ ///
+ /// Summary for the Chapter/Issue
+ ///
+ public string Summary { get; set; }
+ ///
+ /// Language for the Chapter/Issue
+ ///
+ public string Language { get; set; }
+ ///
+ /// Number in the TotalCount of issues
+ ///
+ public int Count { get; set; }
+ ///
+ /// Total number of issues for the series
+ ///
+ public int TotalCount { get; set; }
+
}
}
diff --git a/API/DTOs/Reader/ChapterInfoDto.cs b/API/DTOs/Reader/ChapterInfoDto.cs
index e29f3798c..6af7442dd 100644
--- a/API/DTOs/Reader/ChapterInfoDto.cs
+++ b/API/DTOs/Reader/ChapterInfoDto.cs
@@ -13,6 +13,7 @@ namespace API.DTOs.Reader
public MangaFormat SeriesFormat { get; set; }
public int SeriesId { get; set; }
public int LibraryId { get; set; }
+ public LibraryType LibraryType { get; set; }
public string ChapterTitle { get; set; } = string.Empty;
public int Pages { get; set; }
public string FileName { get; set; }
diff --git a/API/DTOs/SeriesDetailDto.cs b/API/DTOs/SeriesDetailDto.cs
new file mode 100644
index 000000000..e0a1b0ee8
--- /dev/null
+++ b/API/DTOs/SeriesDetailDto.cs
@@ -0,0 +1,28 @@
+using System.Collections.Generic;
+
+namespace API.DTOs;
+
+///
+/// This is a special DTO for a UI page in Kavita. This performs sorting and grouping and returns exactly what UI requires for layout.
+/// This is subject to change, do not rely on this Data model.
+///
+public class SeriesDetailDto
+{
+ ///
+ /// Specials for the Series. These will have their title and range cleaned to remove the special marker and prepare
+ ///
+ public IEnumerable Specials { get; set; }
+ ///
+ /// All Chapters, excluding Specials and single chapters (0 chapter) for a volume
+ ///
+ public IEnumerable Chapters { get; set; }
+ ///
+ /// Just the Volumes for the Series (Excludes Volume 0)
+ ///
+ public IEnumerable Volumes { get; set; }
+ ///
+ /// These are chapters that are in Volume 0 and should be read AFTER the volumes
+ ///
+ public IEnumerable StorylineChapters { get; set; }
+
+}
diff --git a/API/DTOs/SeriesDto.cs b/API/DTOs/SeriesDto.cs
index fc70ce5ed..5f76634ff 100644
--- a/API/DTOs/SeriesDto.cs
+++ b/API/DTOs/SeriesDto.cs
@@ -18,6 +18,10 @@ namespace API.DTOs
///
public int PagesRead { get; set; }
///
+ /// DateTime representing last time the series was Read. Calculated at API-time.
+ ///
+ public DateTime LatestReadDate { get; set; }
+ ///
/// Rating from logged in user. Calculated at API-time.
///
public int UserRating { get; set; }
@@ -29,6 +33,10 @@ namespace API.DTOs
public DateTime Created { get; set; }
+ public bool NameLocked { get; set; }
+ public bool SortNameLocked { get; set; }
+ public bool LocalizedNameLocked { get; set; }
+
public int LibraryId { get; set; }
public string LibraryName { get; set; }
}
diff --git a/API/DTOs/SeriesMetadataDto.cs b/API/DTOs/SeriesMetadataDto.cs
index fbee305ac..23e8f4e52 100644
--- a/API/DTOs/SeriesMetadataDto.cs
+++ b/API/DTOs/SeriesMetadataDto.cs
@@ -56,6 +56,30 @@ namespace API.DTOs
///
public PublicationStatus PublicationStatus { get; set; }
+ public bool LanguageLocked { get; set; }
+ public bool SummaryLocked { get; set; }
+ ///
+ /// Locked by user so metadata updates from scan loop will not override AgeRating
+ ///
+ public bool AgeRatingLocked { get; set; }
+ ///
+ /// Locked by user so metadata updates from scan loop will not override PublicationStatus
+ ///
+ public bool PublicationStatusLocked { get; set; }
+ public bool GenresLocked { get; set; }
+ public bool TagsLocked { get; set; }
+ public bool WriterLocked { get; set; }
+ public bool CharacterLocked { get; set; }
+ public bool ColoristLocked { get; set; }
+ public bool EditorLocked { get; set; }
+ public bool InkerLocked { get; set; }
+ public bool LettererLocked { get; set; }
+ public bool PencillerLocked { get; set; }
+ public bool PublisherLocked { get; set; }
+ public bool TranslatorLocked { get; set; }
+ public bool CoverArtistLocked { get; set; }
+
+
public int SeriesId { get; set; }
}
}
diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs
index 03f853d33..d3abaa313 100644
--- a/API/DTOs/Settings/ServerSettingDTO.cs
+++ b/API/DTOs/Settings/ServerSettingDTO.cs
@@ -37,5 +37,6 @@ namespace API.DTOs.Settings
///
/// If null or empty string, will default back to default install setting aka
public string EmailServiceUrl { get; set; }
+ public string InstallVersion { get; set; }
}
}
diff --git a/API/DTOs/Stats/ServerInfoDto.cs b/API/DTOs/Stats/ServerInfoDto.cs
index 9176a81ff..45a73236b 100644
--- a/API/DTOs/Stats/ServerInfoDto.cs
+++ b/API/DTOs/Stats/ServerInfoDto.cs
@@ -1,4 +1,6 @@
-namespace API.DTOs.Stats
+using API.Entities.Enums;
+
+namespace API.DTOs.Stats
{
public class ServerInfoDto
{
@@ -10,5 +12,39 @@
public int NumOfCores { get; set; }
public int NumberOfLibraries { get; set; }
public bool HasBookmarks { get; set; }
+ ///
+ /// The site theme the install is using
+ ///
+ public string ActiveSiteTheme { get; set; }
+
+ ///
+ /// The reading mode the main user has as a preference
+ ///
+ public ReaderMode MangaReaderMode { get; set; }
+
+ ///
+ /// Number of users on the install
+ ///
+ public int NumberOfUsers { get; set; }
+
+ ///
+ /// Number of collections on the install
+ ///
+ public int NumberOfCollections { get; set; }
+
+ ///
+ /// Number of reading lists on the install (Sum of all users)
+ ///
+ public int NumberOfReadingLists { get; set; }
+
+ ///
+ /// Is OPDS enabled
+ ///
+ public bool OPDSEnabled { get; set; }
+
+ ///
+ /// Total number of files in the instance
+ ///
+ public int TotalFiles { get; set; }
}
}
diff --git a/API/DTOs/Theme/SiteThemeDto.cs b/API/DTOs/Theme/SiteThemeDto.cs
new file mode 100644
index 000000000..e8b0460f9
--- /dev/null
+++ b/API/DTOs/Theme/SiteThemeDto.cs
@@ -0,0 +1,30 @@
+using System;
+using API.Entities.Enums.Theme;
+using API.Services;
+
+namespace API.DTOs.Theme;
+
+public class SiteThemeDto
+{
+ public int Id { get; set; }
+ ///
+ /// Name of the Theme
+ ///
+ public string Name { get; set; }
+ ///
+ /// File path to the content. Stored under .
+ /// Must be a .css file
+ ///
+ public string FileName { get; set; }
+ ///
+ /// Only one theme can have this. Will auto-set this as default for new user accounts
+ ///
+ public bool IsDefault { get; set; }
+ ///
+ /// Where did the theme come from
+ ///
+ public ThemeProvider Provider { get; set; }
+ public DateTime Created { get; set; }
+ public DateTime LastModified { get; set; }
+ public string Selector => "bg-" + Name.ToLower();
+}
diff --git a/API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs b/API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs
new file mode 100644
index 000000000..d4bdb8e09
--- /dev/null
+++ b/API/DTOs/Theme/UpdateDefaultSiteThemeDto.cs
@@ -0,0 +1,6 @@
+namespace API.DTOs.Theme;
+
+public class UpdateDefaultSiteThemeDto
+{
+ public int ThemeId { get; set; }
+}
diff --git a/API/DTOs/UpdateSeriesDto.cs b/API/DTOs/UpdateSeriesDto.cs
index 39054a032..8f10373e4 100644
--- a/API/DTOs/UpdateSeriesDto.cs
+++ b/API/DTOs/UpdateSeriesDto.cs
@@ -6,10 +6,10 @@
public string Name { get; init; }
public string LocalizedName { get; init; }
public string SortName { get; init; }
- public string Summary { get; init; }
- public byte[] CoverImage { get; init; }
- public int UserRating { get; set; }
- public string UserReview { get; set; }
public bool CoverImageLocked { get; set; }
+
+ public bool NameLocked { get; set; }
+ public bool SortNameLocked { get; set; }
+ public bool LocalizedNameLocked { get; set; }
}
}
diff --git a/API/DTOs/UpdateSeriesMetadataDto.cs b/API/DTOs/UpdateSeriesMetadataDto.cs
index dd43167c9..08d3e77e6 100644
--- a/API/DTOs/UpdateSeriesMetadataDto.cs
+++ b/API/DTOs/UpdateSeriesMetadataDto.cs
@@ -6,6 +6,6 @@ namespace API.DTOs
public class UpdateSeriesMetadataDto
{
public SeriesMetadataDto SeriesMetadata { get; set; }
- public ICollection Tags { get; set; }
+ public ICollection CollectionTags { get; set; }
}
-}
\ No newline at end of file
+}
diff --git a/API/DTOs/UpdateUserRole.cs b/API/DTOs/UpdateUserRole.cs
new file mode 100644
index 000000000..a37076d2c
--- /dev/null
+++ b/API/DTOs/UpdateUserRole.cs
@@ -0,0 +1,10 @@
+using System.Collections.Generic;
+using MediatR;
+
+namespace API.DTOs;
+
+public class UpdateUserRole : IRequest
+{
+ public string Username { get; init; }
+ public IList Roles { get; init; }
+}
diff --git a/API/DTOs/Uploads/UploadUrlDto.cs b/API/DTOs/Uploads/UploadUrlDto.cs
new file mode 100644
index 000000000..cd44b78a2
--- /dev/null
+++ b/API/DTOs/Uploads/UploadUrlDto.cs
@@ -0,0 +1,9 @@
+namespace API.DTOs.Uploads;
+
+public class UploadUrlDto
+{
+ ///
+ /// External url
+ ///
+ public string Url { get; set; }
+}
diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs
index 7a7a234e7..dc6fc8b43 100644
--- a/API/DTOs/UserDto.cs
+++ b/API/DTOs/UserDto.cs
@@ -5,8 +5,8 @@ namespace API.DTOs
{
public string Username { get; init; }
public string Email { get; init; }
- public string Token { get; init; }
- public string RefreshToken { get; init; }
+ public string Token { get; set; }
+ public string RefreshToken { get; set; }
public string ApiKey { get; init; }
public UserPreferencesDto Preferences { get; set; }
}
diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs
index c36c9d146..4bfcb2d77 100644
--- a/API/DTOs/UserPreferencesDto.cs
+++ b/API/DTOs/UserPreferencesDto.cs
@@ -1,21 +1,78 @@
-using API.Entities.Enums;
+using API.Entities;
+using API.Entities.Enums;
namespace API.DTOs
{
public class UserPreferencesDto
{
+ ///
+ /// Manga Reader Option: What direction should the next/prev page buttons go
+ ///
public ReadingDirection ReadingDirection { get; set; }
+ ///
+ /// Manga Reader Option: How should the image be scaled to screen
+ ///
public ScalingOption ScalingOption { get; set; }
+ ///
+ /// Manga Reader Option: Which side of a split image should we show first
+ ///
public PageSplitOption PageSplitOption { get; set; }
+ ///
+ /// Manga Reader Option: How the manga reader should perform paging or reading of the file
+ ///
+ /// Webtoon uses scrolling to page, LeftRight uses paging by clicking left/right side of reader, UpDown uses paging
+ /// by clicking top/bottom sides of reader.
+ ///
+ ///
public ReaderMode ReaderMode { get; set; }
+ ///
+ /// Manga Reader Option: How many pages to display in the reader at once
+ ///
+ public LayoutMode LayoutMode { get; set; }
+ ///
+ /// Manga Reader Option: Background color of the reader
+ ///
+ public string BackgroundColor { get; set; } = "#000000";
+ ///
+ /// Manga Reader Option: Allow the menu to close after 6 seconds without interaction
+ ///
public bool AutoCloseMenu { get; set; }
+ ///
+ /// Manga Reader Option: Show screen hints to the user on some actions, ie) pagination direction change
+ ///
+ public bool ShowScreenHints { get; set; } = true;
+ ///
+ /// Book Reader Option: Should the background color be dark
+ ///
public bool BookReaderDarkMode { get; set; } = false;
+ ///
+ /// Book Reader Option: Override extra Margin
+ ///
public int BookReaderMargin { get; set; }
+ ///
+ /// Book Reader Option: Override line-height
+ ///
public int BookReaderLineSpacing { get; set; }
+ ///
+ /// Book Reader Option: Override font size
+ ///
public int BookReaderFontSize { get; set; }
+ ///
+ /// Book Reader Option: Maps to the default Kavita font-family (inherit) or an override
+ ///
public string BookReaderFontFamily { get; set; }
+ ///
+ /// Book Reader Option: Allows tapping on side of screens to paginate
+ ///
public bool BookReaderTapToPaginate { get; set; }
+ ///
+ /// Book Reader Option: What direction should the next/prev page buttons go
+ ///
public ReadingDirection BookReaderReadingDirection { get; set; }
- public bool SiteDarkMode { get; set; }
+ ///
+ /// UI Site Global Setting: The UI theme the user should use.
+ ///
+ /// Should default to Dark
+ public SiteTheme Theme { get; set; }
}
}
diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs
index c1e100d48..6822467a8 100644
--- a/API/Data/DataContext.cs
+++ b/API/Data/DataContext.cs
@@ -40,6 +40,7 @@ namespace API.Data
public DbSet Person { get; set; }
public DbSet Genre { get; set; }
public DbSet Tag { get; set; }
+ public DbSet SiteTheme { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
diff --git a/API/Data/Metadata/ComicInfo.cs b/API/Data/Metadata/ComicInfo.cs
index 0f213d848..040d7f6b7 100644
--- a/API/Data/Metadata/ComicInfo.cs
+++ b/API/Data/Metadata/ComicInfo.cs
@@ -14,6 +14,7 @@ namespace API.Data.Metadata
public string Summary { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Series { get; set; } = string.Empty;
+ public string SeriesSort { get; set; } = string.Empty;
public string Number { get; set; } = string.Empty;
///
/// The total number of items in the series.
@@ -25,7 +26,7 @@ namespace API.Data.Metadata
public int PageCount { get; set; }
// ReSharper disable once InconsistentNaming
///
- /// ISO 639-1 Code to represent the language of the content
+ /// IETF BCP 47 Code to represent the language of the content
///
public string LanguageISO { get; set; } = string.Empty;
///
diff --git a/API/Data/Migrations/20220215163317_SiteTheme.Designer.cs b/API/Data/Migrations/20220215163317_SiteTheme.Designer.cs
new file mode 100644
index 000000000..43b538c9a
--- /dev/null
+++ b/API/Data/Migrations/20220215163317_SiteTheme.Designer.cs
@@ -0,0 +1,1391 @@
+//
+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("20220215163317_SiteTheme")]
+ partial class SiteTheme
+ {
+ 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("ThemeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId")
+ .IsUnique();
+
+ b.HasIndex("ThemeId");
+
+ 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("Count")
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("TEXT");
+
+ b.Property("CoverImageLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("IsSpecial")
+ .HasColumnType("INTEGER");
+
+ b.Property("Language")
+ .HasColumnType("TEXT");
+
+ 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("Summary")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.Property("TitleName")
+ .HasColumnType("TEXT");
+
+ b.Property("TotalCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ 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("Count")
+ .HasColumnType("INTEGER");
+
+ b.Property("Language")
+ .HasColumnType("TEXT");
+
+ b.Property("PublicationStatus")
+ .HasColumnType("INTEGER");
+
+ 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.SiteTheme", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("FileName")
+ .HasColumnType("TEXT");
+
+ b.Property("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .HasColumnType("TEXT");
+
+ b.Property("Provider")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("SiteTheme");
+ });
+
+ 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("ChapterGenre", b =>
+ {
+ b.Property("ChaptersId")
+ .HasColumnType("INTEGER");
+
+ b.Property("GenresId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ChaptersId", "GenresId");
+
+ b.HasIndex("GenresId");
+
+ b.ToTable("ChapterGenre");
+ });
+
+ 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.HasOne("API.Entities.SiteTheme", "Theme")
+ .WithMany()
+ .HasForeignKey("ThemeId");
+
+ b.Navigation("AppUser");
+
+ b.Navigation("Theme");
+ });
+
+ 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.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("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("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.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/20220215163317_SiteTheme.cs b/API/Data/Migrations/20220215163317_SiteTheme.cs
new file mode 100644
index 000000000..e2f519f8b
--- /dev/null
+++ b/API/Data/Migrations/20220215163317_SiteTheme.cs
@@ -0,0 +1,79 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace API.Data.Migrations
+{
+ public partial class SiteTheme : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "SiteDarkMode",
+ table: "AppUserPreferences");
+
+ migrationBuilder.AddColumn(
+ name: "ThemeId",
+ table: "AppUserPreferences",
+ type: "INTEGER",
+ nullable: true);
+
+ migrationBuilder.CreateTable(
+ name: "SiteTheme",
+ columns: table => new
+ {
+ Id = table.Column(type: "INTEGER", nullable: false)
+ .Annotation("Sqlite:Autoincrement", true),
+ Name = table.Column(type: "TEXT", nullable: true),
+ NormalizedName = table.Column(type: "TEXT", nullable: true),
+ FileName = table.Column(type: "TEXT", nullable: true),
+ IsDefault = table.Column(type: "INTEGER", nullable: false),
+ Provider = table.Column(type: "INTEGER", nullable: false),
+ Created = table.Column(type: "TEXT", nullable: false),
+ LastModified = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_SiteTheme", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_AppUserPreferences_ThemeId",
+ table: "AppUserPreferences",
+ column: "ThemeId");
+
+ migrationBuilder.AddForeignKey(
+ name: "FK_AppUserPreferences_SiteTheme_ThemeId",
+ table: "AppUserPreferences",
+ column: "ThemeId",
+ principalTable: "SiteTheme",
+ principalColumn: "Id");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "FK_AppUserPreferences_SiteTheme_ThemeId",
+ table: "AppUserPreferences");
+
+ migrationBuilder.DropTable(
+ name: "SiteTheme");
+
+ migrationBuilder.DropIndex(
+ name: "IX_AppUserPreferences_ThemeId",
+ table: "AppUserPreferences");
+
+ migrationBuilder.DropColumn(
+ name: "ThemeId",
+ table: "AppUserPreferences");
+
+ migrationBuilder.AddColumn(
+ name: "SiteDarkMode",
+ table: "AppUserPreferences",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: false);
+ }
+ }
+}
diff --git a/API/Data/Migrations/20220303205301_SeriesLockedFields.Designer.cs b/API/Data/Migrations/20220303205301_SeriesLockedFields.Designer.cs
new file mode 100644
index 000000000..00fc7a10f
--- /dev/null
+++ b/API/Data/Migrations/20220303205301_SeriesLockedFields.Designer.cs
@@ -0,0 +1,1448 @@
+//
+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("20220303205301_SeriesLockedFields")]
+ partial class SeriesLockedFields
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "6.0.2");
+
+ 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