diff --git a/API.Tests/Helpers/RandfHelper.cs b/API.Tests/Helpers/RandfHelper.cs
new file mode 100644
index 000000000..d8c007df7
--- /dev/null
+++ b/API.Tests/Helpers/RandfHelper.cs
@@ -0,0 +1,124 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+
+namespace API.Tests.Helpers;
+
+public class RandfHelper
+{
+ private static readonly Random Random = new ();
+
+ ///
+ /// Returns true if all simple fields are equal
+ ///
+ ///
+ ///
+ /// fields to ignore, note that the names are very weird sometimes
+ ///
+ ///
+ ///
+ public static bool AreSimpleFieldsEqual(object obj1, object obj2, IList ignoreFields)
+ {
+ if (obj1 == null || obj2 == null)
+ throw new ArgumentNullException("Neither object can be null.");
+
+ Type type1 = obj1.GetType();
+ Type type2 = obj2.GetType();
+
+ if (type1 != type2)
+ throw new ArgumentException("Objects must be of the same type.");
+
+ FieldInfo[] fields = type1.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic);
+
+ foreach (var field in fields)
+ {
+ if (field.IsInitOnly) continue;
+ if (ignoreFields.Contains(field.Name)) continue;
+
+ Type fieldType = field.FieldType;
+
+ if (IsRelevantType(fieldType))
+ {
+ object value1 = field.GetValue(obj1);
+ object value2 = field.GetValue(obj2);
+
+ if (!Equals(value1, value2))
+ {
+ throw new ArgumentException("Fields must be of the same type: " + field.Name + " was " + value1 + " and " + value2);
+ }
+ }
+ }
+
+ return true;
+ }
+
+ private static bool IsRelevantType(Type type)
+ {
+ return type.IsPrimitive
+ || type == typeof(string)
+ || type.IsEnum;
+ }
+
+ ///
+ /// Sets all simple fields of the given object to a random value
+ ///
+ ///
+ /// Simple is, primitive, string, or enum
+ ///
+ public static void SetRandomValues(object obj)
+ {
+ if (obj == null) throw new ArgumentNullException(nameof(obj));
+
+ Type type = obj.GetType();
+ FieldInfo[] fields = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
+
+ foreach (var field in fields)
+ {
+ if (field.IsInitOnly) continue; // Skip readonly fields
+
+ object value = GenerateRandomValue(field.FieldType);
+ if (value != null)
+ {
+ field.SetValue(obj, value);
+ }
+ }
+ }
+
+ private static object GenerateRandomValue(Type type)
+ {
+ if (type == typeof(int))
+ return Random.Next();
+ if (type == typeof(float))
+ return (float)Random.NextDouble() * 100;
+ if (type == typeof(double))
+ return Random.NextDouble() * 100;
+ if (type == typeof(bool))
+ return Random.Next(2) == 1;
+ if (type == typeof(char))
+ return (char)Random.Next('A', 'Z' + 1);
+ if (type == typeof(byte))
+ return (byte)Random.Next(0, 256);
+ if (type == typeof(short))
+ return (short)Random.Next(short.MinValue, short.MaxValue);
+ if (type == typeof(long))
+ return (long)(Random.NextDouble() * long.MaxValue);
+ if (type == typeof(string))
+ return GenerateRandomString(10);
+ if (type.IsEnum)
+ {
+ var values = Enum.GetValues(type);
+ return values.GetValue(Random.Next(values.Length));
+ }
+
+ // Unsupported type
+ return null;
+ }
+
+ private static string GenerateRandomString(int length)
+ {
+ const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ return new string(Enumerable.Repeat(chars, length)
+ .Select(s => s[Random.Next(s.Length)]).ToArray());
+ }
+}
diff --git a/API.Tests/Services/ReadingProfileServiceTest.cs b/API.Tests/Services/ReadingProfileServiceTest.cs
new file mode 100644
index 000000000..b3d81e5ac
--- /dev/null
+++ b/API.Tests/Services/ReadingProfileServiceTest.cs
@@ -0,0 +1,561 @@
+using System.Linq;
+using System.Threading.Tasks;
+using API.Data.Repositories;
+using API.DTOs;
+using API.Entities;
+using API.Entities.Enums;
+using API.Helpers.Builders;
+using API.Services;
+using API.Tests.Helpers;
+using Kavita.Common;
+using Microsoft.EntityFrameworkCore;
+using NSubstitute;
+using Xunit;
+
+namespace API.Tests.Services;
+
+public class ReadingProfileServiceTest: AbstractDbTest
+{
+
+ ///
+ /// Does not add a default reading profile
+ ///
+ ///
+ public async Task<(ReadingProfileService, AppUser, Library, Series)> Setup()
+ {
+ var user = new AppUserBuilder("amelia", "amelia@localhost").Build();
+ Context.AppUser.Add(user);
+ await UnitOfWork.CommitAsync();
+
+ var series = new SeriesBuilder("Spice and Wolf").Build();
+
+ var library = new LibraryBuilder("Manga")
+ .WithSeries(series)
+ .Build();
+
+ user.Libraries.Add(library);
+ await UnitOfWork.CommitAsync();
+
+ var rps = new ReadingProfileService(UnitOfWork, Substitute.For(), Mapper);
+ user = await UnitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.UserPreferences);
+
+ return (rps, user, library, series);
+ }
+
+ [Fact]
+ public async Task ImplicitProfileFirst()
+ {
+ await ResetDb();
+ var (rps, user, library, series) = await Setup();
+
+ var profile = new AppUserReadingProfileBuilder(user.Id)
+ .WithKind(ReadingProfileKind.Implicit)
+ .WithSeries(series)
+ .WithName("Implicit Profile")
+ .Build();
+
+ var profile2 = new AppUserReadingProfileBuilder(user.Id)
+ .WithSeries(series)
+ .WithName("Non-implicit Profile")
+ .Build();
+
+ user.ReadingProfiles.Add(profile);
+ user.ReadingProfiles.Add(profile2);
+ await UnitOfWork.CommitAsync();
+
+ var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
+ Assert.NotNull(seriesProfile);
+ Assert.Equal("Implicit Profile", seriesProfile.Name);
+
+ // Find parent
+ seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id, true);
+ Assert.NotNull(seriesProfile);
+ Assert.Equal("Non-implicit Profile", seriesProfile.Name);
+ }
+
+ [Fact]
+ public async Task CantDeleteDefaultReadingProfile()
+ {
+ await ResetDb();
+ var (rps, user, _, _) = await Setup();
+
+ var profile = new AppUserReadingProfileBuilder(user.Id)
+ .WithKind(ReadingProfileKind.Default)
+ .Build();
+ Context.AppUserReadingProfiles.Add(profile);
+ await UnitOfWork.CommitAsync();
+
+ await Assert.ThrowsAsync(async () =>
+ {
+ await rps.DeleteReadingProfile(user.Id, profile.Id);
+ });
+
+ var profile2 = new AppUserReadingProfileBuilder(user.Id).Build();
+ Context.AppUserReadingProfiles.Add(profile2);
+ await UnitOfWork.CommitAsync();
+
+ await rps.DeleteReadingProfile(user.Id, profile2.Id);
+ await UnitOfWork.CommitAsync();
+
+ var allProfiles = await Context.AppUserReadingProfiles.ToListAsync();
+ Assert.Single(allProfiles);
+ }
+
+ [Fact]
+ public async Task CreateImplicitSeriesReadingProfile()
+ {
+ await ResetDb();
+ var (rps, user, _, series) = await Setup();
+
+ var dto = new UserReadingProfileDto
+ {
+ ReaderMode = ReaderMode.Webtoon,
+ ScalingOption = ScalingOption.FitToHeight,
+ WidthOverride = 53,
+ };
+
+ await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto);
+
+ var profile = await rps.GetReadingProfileForSeries(user.Id, series.Id);
+ Assert.NotNull(profile);
+ Assert.Contains(profile.SeriesIds, s => s == series.Id);
+ Assert.Equal(ReadingProfileKind.Implicit, profile.Kind);
+ }
+
+ [Fact]
+ public async Task UpdateImplicitReadingProfile_DoesNotCreateNew()
+ {
+ await ResetDb();
+ var (rps, user, _, series) = await Setup();
+
+ var dto = new UserReadingProfileDto
+ {
+ ReaderMode = ReaderMode.Webtoon,
+ ScalingOption = ScalingOption.FitToHeight,
+ WidthOverride = 53,
+ };
+
+ await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto);
+
+ var profile = await rps.GetReadingProfileForSeries(user.Id, series.Id);
+ Assert.NotNull(profile);
+ Assert.Contains(profile.SeriesIds, s => s == series.Id);
+ Assert.Equal(ReadingProfileKind.Implicit, profile.Kind);
+
+ dto = new UserReadingProfileDto
+ {
+ ReaderMode = ReaderMode.LeftRight,
+ };
+
+ await rps.UpdateImplicitReadingProfile(user.Id, series.Id, dto);
+ profile = await rps.GetReadingProfileForSeries(user.Id, series.Id);
+ Assert.NotNull(profile);
+ Assert.Contains(profile.SeriesIds, s => s == series.Id);
+ Assert.Equal(ReadingProfileKind.Implicit, profile.Kind);
+ Assert.Equal(ReaderMode.LeftRight, profile.ReaderMode);
+
+ var implicitCount = await Context.AppUserReadingProfiles
+ .Where(p => p.Kind == ReadingProfileKind.Implicit)
+ .CountAsync();
+ Assert.Equal(1, implicitCount);
+ }
+
+ [Fact]
+ public async Task GetCorrectProfile()
+ {
+ await ResetDb();
+ var (rps, user, lib, series) = await Setup();
+
+ var profile = new AppUserReadingProfileBuilder(user.Id)
+ .WithSeries(series)
+ .WithName("Series Specific")
+ .Build();
+ var profile2 = new AppUserReadingProfileBuilder(user.Id)
+ .WithLibrary(lib)
+ .WithName("Library Specific")
+ .Build();
+ var profile3 = new AppUserReadingProfileBuilder(user.Id)
+ .WithKind(ReadingProfileKind.Default)
+ .WithName("Global")
+ .Build();
+ Context.AppUserReadingProfiles.Add(profile);
+ Context.AppUserReadingProfiles.Add(profile2);
+ Context.AppUserReadingProfiles.Add(profile3);
+
+ var series2 = new SeriesBuilder("Rainbows After Storms").Build();
+ lib.Series.Add(series2);
+
+ var lib2 = new LibraryBuilder("Manga2").Build();
+ var series3 = new SeriesBuilder("A Tropical Fish Yearns for Snow").Build();
+ lib2.Series.Add(series3);
+
+ user.Libraries.Add(lib2);
+ await UnitOfWork.CommitAsync();
+
+ var p = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
+ Assert.NotNull(p);
+ Assert.Equal("Series Specific", p.Name);
+
+ p = await rps.GetReadingProfileDtoForSeries(user.Id, series2.Id);
+ Assert.NotNull(p);
+ Assert.Equal("Library Specific", p.Name);
+
+ p = await rps.GetReadingProfileDtoForSeries(user.Id, series3.Id);
+ Assert.NotNull(p);
+ Assert.Equal("Global", p.Name);
+ }
+
+ [Fact]
+ public async Task ReplaceReadingProfile()
+ {
+ await ResetDb();
+ var (rps, user, lib, series) = await Setup();
+
+ var profile1 = new AppUserReadingProfileBuilder(user.Id)
+ .WithSeries(series)
+ .WithName("Profile 1")
+ .Build();
+
+ var profile2 = new AppUserReadingProfileBuilder(user.Id)
+ .WithName("Profile 2")
+ .Build();
+
+ Context.AppUserReadingProfiles.Add(profile1);
+ Context.AppUserReadingProfiles.Add(profile2);
+ await UnitOfWork.CommitAsync();
+
+ var profile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
+ Assert.NotNull(profile);
+ Assert.Equal("Profile 1", profile.Name);
+
+ await rps.AddProfileToSeries(user.Id, profile2.Id, series.Id);
+ profile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
+ Assert.NotNull(profile);
+ Assert.Equal("Profile 2", profile.Name);
+ }
+
+ [Fact]
+ public async Task DeleteReadingProfile()
+ {
+ await ResetDb();
+ var (rps, user, lib, series) = await Setup();
+
+ var profile1 = new AppUserReadingProfileBuilder(user.Id)
+ .WithSeries(series)
+ .WithName("Profile 1")
+ .Build();
+
+ Context.AppUserReadingProfiles.Add(profile1);
+ await UnitOfWork.CommitAsync();
+
+ await rps.ClearSeriesProfile(user.Id, series.Id);
+ var profiles = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id);
+ Assert.DoesNotContain(profiles, rp => rp.SeriesIds.Contains(series.Id));
+
+ }
+
+ [Fact]
+ public async Task BulkAddReadingProfiles()
+ {
+ await ResetDb();
+ var (rps, user, lib, series) = await Setup();
+
+ for (var i = 0; i < 10; i++)
+ {
+ var generatedSeries = new SeriesBuilder($"Generated Series #{i}").Build();
+ lib.Series.Add(generatedSeries);
+ }
+
+ var profile = new AppUserReadingProfileBuilder(user.Id)
+ .WithSeries(series)
+ .WithName("Profile")
+ .Build();
+ Context.AppUserReadingProfiles.Add(profile);
+
+ var profile2 = new AppUserReadingProfileBuilder(user.Id)
+ .WithSeries(series)
+ .WithName("Profile2")
+ .Build();
+ Context.AppUserReadingProfiles.Add(profile2);
+
+ await UnitOfWork.CommitAsync();
+
+ var someSeriesIds = lib.Series.Take(lib.Series.Count / 2).Select(s => s.Id).ToList();
+ await rps.BulkAddProfileToSeries(user.Id, profile.Id, someSeriesIds);
+
+ foreach (var id in someSeriesIds)
+ {
+ var foundProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id);
+ Assert.NotNull(foundProfile);
+ Assert.Equal(profile.Id, foundProfile.Id);
+ }
+
+ var allIds = lib.Series.Select(s => s.Id).ToList();
+ await rps.BulkAddProfileToSeries(user.Id, profile2.Id, allIds);
+
+ foreach (var id in allIds)
+ {
+ var foundProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id);
+ Assert.NotNull(foundProfile);
+ Assert.Equal(profile2.Id, foundProfile.Id);
+ }
+
+
+ }
+
+ [Fact]
+ public async Task BulkAssignDeletesImplicit()
+ {
+ await ResetDb();
+ var (rps, user, lib, series) = await Setup();
+
+ var implicitProfile = Mapper.Map(new AppUserReadingProfileBuilder(user.Id)
+ .Build());
+
+ var profile = new AppUserReadingProfileBuilder(user.Id)
+ .WithName("Profile 1")
+ .Build();
+ Context.AppUserReadingProfiles.Add(profile);
+
+ for (var i = 0; i < 10; i++)
+ {
+ var generatedSeries = new SeriesBuilder($"Generated Series #{i}").Build();
+ lib.Series.Add(generatedSeries);
+ }
+ await UnitOfWork.CommitAsync();
+
+ var ids = lib.Series.Select(s => s.Id).ToList();
+
+ foreach (var id in ids)
+ {
+ await rps.UpdateImplicitReadingProfile(user.Id, id, implicitProfile);
+ var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id);
+ Assert.NotNull(seriesProfile);
+ Assert.Equal(ReadingProfileKind.Implicit, seriesProfile.Kind);
+ }
+
+ await rps.BulkAddProfileToSeries(user.Id, profile.Id, ids);
+
+ foreach (var id in ids)
+ {
+ var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, id);
+ Assert.NotNull(seriesProfile);
+ Assert.Equal(ReadingProfileKind.User, seriesProfile.Kind);
+ }
+
+ var implicitCount = await Context.AppUserReadingProfiles
+ .Where(p => p.Kind == ReadingProfileKind.Implicit)
+ .CountAsync();
+ Assert.Equal(0, implicitCount);
+ }
+
+ [Fact]
+ public async Task AddDeletesImplicit()
+ {
+ await ResetDb();
+ var (rps, user, lib, series) = await Setup();
+
+ var implicitProfile = Mapper.Map(new AppUserReadingProfileBuilder(user.Id)
+ .WithKind(ReadingProfileKind.Implicit)
+ .Build());
+
+ var profile = new AppUserReadingProfileBuilder(user.Id)
+ .WithName("Profile 1")
+ .Build();
+ Context.AppUserReadingProfiles.Add(profile);
+ await UnitOfWork.CommitAsync();
+
+ await rps.UpdateImplicitReadingProfile(user.Id, series.Id, implicitProfile);
+
+ var seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
+ Assert.NotNull(seriesProfile);
+ Assert.Equal(ReadingProfileKind.Implicit, seriesProfile.Kind);
+
+ await rps.AddProfileToSeries(user.Id, profile.Id, series.Id);
+
+ seriesProfile = await rps.GetReadingProfileDtoForSeries(user.Id, series.Id);
+ Assert.NotNull(seriesProfile);
+ Assert.Equal(ReadingProfileKind.User, seriesProfile.Kind);
+
+ var implicitCount = await Context.AppUserReadingProfiles
+ .Where(p => p.Kind == ReadingProfileKind.Implicit)
+ .CountAsync();
+ Assert.Equal(0, implicitCount);
+ }
+
+ [Fact]
+ public async Task CreateReadingProfile()
+ {
+ await ResetDb();
+ var (rps, user, lib, series) = await Setup();
+
+ var dto = new UserReadingProfileDto
+ {
+ Name = "Profile 1",
+ ReaderMode = ReaderMode.LeftRight,
+ EmulateBook = false,
+ };
+
+ await rps.CreateReadingProfile(user.Id, dto);
+
+ var dto2 = new UserReadingProfileDto
+ {
+ Name = "Profile 2",
+ ReaderMode = ReaderMode.LeftRight,
+ EmulateBook = false,
+ };
+
+ await rps.CreateReadingProfile(user.Id, dto2);
+
+ var dto3 = new UserReadingProfileDto
+ {
+ Name = "Profile 1", // Not unique name
+ ReaderMode = ReaderMode.LeftRight,
+ EmulateBook = false,
+ };
+
+ await Assert.ThrowsAsync(async () =>
+ {
+ await rps.CreateReadingProfile(user.Id, dto3);
+ });
+
+ var allProfiles = Context.AppUserReadingProfiles.ToList();
+ Assert.Equal(2, allProfiles.Count);
+ }
+
+ [Fact]
+ public async Task ClearSeriesProfile_RemovesImplicitAndUnlinksExplicit()
+ {
+ await ResetDb();
+ var (rps, user, _, series) = await Setup();
+
+ var implicitProfile = new AppUserReadingProfileBuilder(user.Id)
+ .WithSeries(series)
+ .WithKind(ReadingProfileKind.Implicit)
+ .WithName("Implicit Profile")
+ .Build();
+
+ var explicitProfile = new AppUserReadingProfileBuilder(user.Id)
+ .WithSeries(series)
+ .WithName("Explicit Profile")
+ .Build();
+
+ Context.AppUserReadingProfiles.Add(implicitProfile);
+ Context.AppUserReadingProfiles.Add(explicitProfile);
+ await UnitOfWork.CommitAsync();
+
+ var allBefore = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id);
+ Assert.Equal(2, allBefore.Count(rp => rp.SeriesIds.Contains(series.Id)));
+
+ await rps.ClearSeriesProfile(user.Id, series.Id);
+
+ var remainingProfiles = await Context.AppUserReadingProfiles.ToListAsync();
+ Assert.Single(remainingProfiles);
+ Assert.Equal("Explicit Profile", remainingProfiles[0].Name);
+ Assert.Empty(remainingProfiles[0].SeriesIds);
+
+ var profilesForSeries = await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id);
+ Assert.DoesNotContain(profilesForSeries, rp => rp.SeriesIds.Contains(series.Id));
+ }
+
+ [Fact]
+ public async Task AddProfileToLibrary_AddsAndOverridesExisting()
+ {
+ await ResetDb();
+ var (rps, user, lib, _) = await Setup();
+
+ var profile = new AppUserReadingProfileBuilder(user.Id)
+ .WithName("Library Profile")
+ .Build();
+ Context.AppUserReadingProfiles.Add(profile);
+ await UnitOfWork.CommitAsync();
+
+ await rps.AddProfileToLibrary(user.Id, profile.Id, lib.Id);
+ await UnitOfWork.CommitAsync();
+
+ var linkedProfile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id))
+ .FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id));
+ Assert.NotNull(linkedProfile);
+ Assert.Equal(profile.Id, linkedProfile.Id);
+
+ var newProfile = new AppUserReadingProfileBuilder(user.Id)
+ .WithName("New Profile")
+ .Build();
+ Context.AppUserReadingProfiles.Add(newProfile);
+ await UnitOfWork.CommitAsync();
+
+ await rps.AddProfileToLibrary(user.Id, newProfile.Id, lib.Id);
+ await UnitOfWork.CommitAsync();
+
+ linkedProfile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id))
+ .FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id));
+ Assert.NotNull(linkedProfile);
+ Assert.Equal(newProfile.Id, linkedProfile.Id);
+ }
+
+ [Fact]
+ public async Task ClearLibraryProfile_RemovesImplicitOrUnlinksExplicit()
+ {
+ await ResetDb();
+ var (rps, user, lib, _) = await Setup();
+
+ var implicitProfile = new AppUserReadingProfileBuilder(user.Id)
+ .WithKind(ReadingProfileKind.Implicit)
+ .WithLibrary(lib)
+ .Build();
+ Context.AppUserReadingProfiles.Add(implicitProfile);
+ await UnitOfWork.CommitAsync();
+
+ await rps.ClearLibraryProfile(user.Id, lib.Id);
+ var profile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id))
+ .FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id));
+ Assert.Null(profile);
+
+ var explicitProfile = new AppUserReadingProfileBuilder(user.Id)
+ .WithLibrary(lib)
+ .Build();
+ Context.AppUserReadingProfiles.Add(explicitProfile);
+ await UnitOfWork.CommitAsync();
+
+ await rps.ClearLibraryProfile(user.Id, lib.Id);
+ profile = (await UnitOfWork.AppUserReadingProfileRepository.GetProfilesForUser(user.Id))
+ .FirstOrDefault(rp => rp.LibraryIds.Contains(lib.Id));
+ Assert.Null(profile);
+
+ var stillExists = await Context.AppUserReadingProfiles.FindAsync(explicitProfile.Id);
+ Assert.NotNull(stillExists);
+ }
+
+ ///
+ /// As response to #3793, I'm not sure if we want to keep this. It's not the most nice. But I think the idea of this test
+ /// is worth having.
+ ///
+ [Fact]
+ public void UpdateFields_UpdatesAll()
+ {
+ // Repeat to ensure booleans are flipped and actually tested
+ for (int i = 0; i < 10; i++)
+ {
+ var profile = new AppUserReadingProfile();
+ var dto = new UserReadingProfileDto();
+
+ RandfHelper.SetRandomValues(profile);
+ RandfHelper.SetRandomValues(dto);
+
+ ReadingProfileService.UpdateReaderProfileFields(profile, dto);
+
+ var newDto = Mapper.Map(profile);
+
+ Assert.True(RandfHelper.AreSimpleFieldsEqual(dto, newDto,
+ ["k__BackingField", "k__BackingField"]));
+ }
+ }
+
+
+
+ protected override async Task ResetDb()
+ {
+ Context.AppUserReadingProfiles.RemoveRange(Context.AppUserReadingProfiles);
+ await UnitOfWork.CommitAsync();
+ }
+}
diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs
index c504e1ce7..d8b9164af 100644
--- a/API/Controllers/AccountController.cs
+++ b/API/Controllers/AccountController.cs
@@ -153,6 +153,9 @@ public class AccountController : BaseApiController
// Assign default streams
AddDefaultStreamsToUser(user);
+ // Assign default reading profile
+ await AddDefaultReadingProfileToUser(user);
+
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
if (string.IsNullOrEmpty(token)) return BadRequest(await _localizationService.Get("en", "confirm-token-gen"));
if (!await ConfirmEmailToken(token, user)) return BadRequest(await _localizationService.Get("en", "validate-email", token));
@@ -609,7 +612,7 @@ public class AccountController : BaseApiController
}
///
- /// Requests the Invite Url for the UserId. Will return error if user is already validated.
+ /// Requests the Invite Url for the AppUserId. Will return error if user is already validated.
///
///
/// Include the "https://ip:port/" in the generated link
@@ -669,6 +672,9 @@ public class AccountController : BaseApiController
// Assign default streams
AddDefaultStreamsToUser(user);
+ // Assign default reading profile
+ await AddDefaultReadingProfileToUser(user);
+
// Assign Roles
var roles = dto.Roles;
var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole);
@@ -779,6 +785,16 @@ public class AccountController : BaseApiController
}
}
+ private async Task AddDefaultReadingProfileToUser(AppUser user)
+ {
+ var profile = new AppUserReadingProfileBuilder(user.Id)
+ .WithName("Default Profile")
+ .WithKind(ReadingProfileKind.Default)
+ .Build();
+ _unitOfWork.AppUserReadingProfileRepository.Add(profile);
+ await _unitOfWork.CommitAsync();
+ }
+
///
/// Last step in authentication flow, confirms the email token for email
///
diff --git a/API/Controllers/PluginController.cs b/API/Controllers/PluginController.cs
index c7f48cf54..f39462bbf 100644
--- a/API/Controllers/PluginController.cs
+++ b/API/Controllers/PluginController.cs
@@ -45,7 +45,7 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService
throw new KavitaUnauthenticatedUserException();
}
var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId);
- logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({UserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId);
+ logger.LogInformation("Plugin {PluginName} has authenticated with {UserName} ({AppUserId})'s API Key", pluginName.Replace(Environment.NewLine, string.Empty), user!.UserName, userId);
return new UserDto
{
diff --git a/API/Controllers/ReadingProfileController.cs b/API/Controllers/ReadingProfileController.cs
new file mode 100644
index 000000000..bc1b4fa52
--- /dev/null
+++ b/API/Controllers/ReadingProfileController.cs
@@ -0,0 +1,198 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using API.Data;
+using API.Data.Repositories;
+using API.DTOs;
+using API.Extensions;
+using API.Services;
+using AutoMapper;
+using Kavita.Common;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace API.Controllers;
+
+[Route("api/reading-profile")]
+public class ReadingProfileController(ILogger logger, IUnitOfWork unitOfWork,
+ IReadingProfileService readingProfileService): BaseApiController
+{
+
+ ///
+ /// Gets all non-implicit reading profiles for a user
+ ///
+ ///
+ [HttpGet("all")]
+ public async Task>> GetAllReadingProfiles()
+ {
+ return Ok(await unitOfWork.AppUserReadingProfileRepository.GetProfilesDtoForUser(User.GetUserId(), true));
+ }
+
+ ///
+ /// Returns the ReadingProfile that should be applied to the given series, walks up the tree.
+ /// Series -> Library -> Default
+ ///
+ ///
+ ///
+ ///
+ [HttpGet("{seriesId:int}")]
+ public async Task> GetProfileForSeries(int seriesId, [FromQuery] bool skipImplicit)
+ {
+ return Ok(await readingProfileService.GetReadingProfileDtoForSeries(User.GetUserId(), seriesId, skipImplicit));
+ }
+
+ ///
+ /// Returns the (potential) Reading Profile bound to the library
+ ///
+ ///
+ ///
+ [HttpGet("library")]
+ public async Task> GetProfileForLibrary(int libraryId)
+ {
+ return Ok(await readingProfileService.GetReadingProfileDtoForLibrary(User.GetUserId(), libraryId));
+ }
+
+ ///
+ /// Creates a new reading profile for the current user
+ ///
+ ///
+ ///
+ [HttpPost("create")]
+ public async Task> CreateReadingProfile([FromBody] UserReadingProfileDto dto)
+ {
+ return Ok(await readingProfileService.CreateReadingProfile(User.GetUserId(), dto));
+ }
+
+ ///
+ /// Promotes the implicit profile to a user profile. Removes the series from other profiles
+ ///
+ ///
+ ///
+ [HttpPost("promote")]
+ public async Task> PromoteImplicitReadingProfile([FromQuery] int profileId)
+ {
+ return Ok(await readingProfileService.PromoteImplicitProfile(User.GetUserId(), profileId));
+ }
+
+ ///
+ /// Update the implicit reading profile for a series, creates one if none exists
+ ///
+ /// Any modification to the reader settings during reading will create an implicit profile. Use "update-parent" to save to the bound series profile.
+ ///
+ ///
+ ///
+ [HttpPost("series")]
+ public async Task> UpdateReadingProfileForSeries([FromBody] UserReadingProfileDto dto, [FromQuery] int seriesId)
+ {
+ var updatedProfile = await readingProfileService.UpdateImplicitReadingProfile(User.GetUserId(), seriesId, dto);
+ return Ok(updatedProfile);
+ }
+
+ ///
+ /// Updates the non-implicit reading profile for the given series, and removes implicit profiles
+ ///
+ ///
+ ///
+ ///
+ [HttpPost("update-parent")]
+ public async Task> UpdateParentProfileForSeries([FromBody] UserReadingProfileDto dto, [FromQuery] int seriesId)
+ {
+ var newParentProfile = await readingProfileService.UpdateParent(User.GetUserId(), seriesId, dto);
+ return Ok(newParentProfile);
+ }
+
+ ///
+ /// Updates the given reading profile, must belong to the current user
+ ///
+ ///
+ /// The updated reading profile
+ ///
+ /// This does not update connected series and libraries.
+ ///
+ [HttpPost]
+ public async Task> UpdateReadingProfile(UserReadingProfileDto dto)
+ {
+ return Ok(await readingProfileService.UpdateReadingProfile(User.GetUserId(), dto));
+ }
+
+ ///
+ /// Deletes the given profile, requires the profile to belong to the logged-in user
+ ///
+ ///
+ ///
+ ///
+ ///
+ [HttpDelete]
+ public async Task DeleteReadingProfile([FromQuery] int profileId)
+ {
+ await readingProfileService.DeleteReadingProfile(User.GetUserId(), profileId);
+ return Ok();
+ }
+
+ ///
+ /// Sets the reading profile for a given series, removes the old one
+ ///
+ ///
+ ///
+ ///
+ [HttpPost("series/{seriesId:int}")]
+ public async Task AddProfileToSeries(int seriesId, [FromQuery] int profileId)
+ {
+ await readingProfileService.AddProfileToSeries(User.GetUserId(), profileId, seriesId);
+ return Ok();
+ }
+
+ ///
+ /// Clears the reading profile for the given series for the currently logged-in user
+ ///
+ ///
+ ///
+ [HttpDelete("series/{seriesId:int}")]
+ public async Task ClearSeriesProfile(int seriesId)
+ {
+ await readingProfileService.ClearSeriesProfile(User.GetUserId(), seriesId);
+ return Ok();
+ }
+
+ ///
+ /// Sets the reading profile for a given library, removes the old one
+ ///
+ ///
+ ///
+ ///
+ [HttpPost("library/{libraryId:int}")]
+ public async Task AddProfileToLibrary(int libraryId, [FromQuery] int profileId)
+ {
+ await readingProfileService.AddProfileToLibrary(User.GetUserId(), profileId, libraryId);
+ return Ok();
+ }
+
+ ///
+ /// Clears the reading profile for the given library for the currently logged-in user
+ ///
+ ///
+ ///
+ ///
+ [HttpDelete("library/{libraryId:int}")]
+ public async Task ClearLibraryProfile(int libraryId)
+ {
+ await readingProfileService.ClearLibraryProfile(User.GetUserId(), libraryId);
+ return Ok();
+ }
+
+ ///
+ /// Assigns the reading profile to all passes series, and deletes their implicit profiles
+ ///
+ ///
+ ///
+ ///
+ [HttpPost("bulk")]
+ public async Task BulkAddReadingProfile([FromQuery] int profileId, [FromBody] IList seriesIds)
+ {
+ await readingProfileService.BulkAddProfileToSeries(User.GetUserId(), profileId, seriesIds);
+ return Ok();
+ }
+
+}
diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs
index e5cfb626a..17ebc758e 100644
--- a/API/Controllers/UsersController.cs
+++ b/API/Controllers/UsersController.cs
@@ -103,39 +103,13 @@ public class UsersController : BaseApiController
var existingPreferences = user!.UserPreferences;
- existingPreferences.ReadingDirection = preferencesDto.ReadingDirection;
- existingPreferences.ScalingOption = preferencesDto.ScalingOption;
- existingPreferences.PageSplitOption = preferencesDto.PageSplitOption;
- existingPreferences.AutoCloseMenu = preferencesDto.AutoCloseMenu;
- existingPreferences.ShowScreenHints = preferencesDto.ShowScreenHints;
- existingPreferences.EmulateBook = preferencesDto.EmulateBook;
- 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.BookReaderFontSize = preferencesDto.BookReaderFontSize;
- existingPreferences.BookReaderTapToPaginate = preferencesDto.BookReaderTapToPaginate;
- existingPreferences.BookReaderReadingDirection = preferencesDto.BookReaderReadingDirection;
- existingPreferences.BookReaderWritingStyle = preferencesDto.BookReaderWritingStyle;
- existingPreferences.BookThemeName = preferencesDto.BookReaderThemeName;
- existingPreferences.BookReaderLayoutMode = preferencesDto.BookReaderLayoutMode;
- existingPreferences.BookReaderImmersiveMode = preferencesDto.BookReaderImmersiveMode;
existingPreferences.GlobalPageLayoutMode = preferencesDto.GlobalPageLayoutMode;
existingPreferences.BlurUnreadSummaries = preferencesDto.BlurUnreadSummaries;
- existingPreferences.LayoutMode = preferencesDto.LayoutMode;
existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize;
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
- existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate;
- existingPreferences.AllowAutomaticWebtoonReaderDetection = preferencesDto.AllowAutomaticWebtoonReaderDetection;
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
existingPreferences.ShareReviews = preferencesDto.ShareReviews;
- existingPreferences.PdfTheme = preferencesDto.PdfTheme;
- existingPreferences.PdfScrollMode = preferencesDto.PdfScrollMode;
- existingPreferences.PdfSpreadMode = preferencesDto.PdfSpreadMode;
-
if (await _licenseService.HasActiveLicense())
{
existingPreferences.AniListScrobblingEnabled = preferencesDto.AniListScrobblingEnabled;
diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs
index 6645a8f39..46f42306e 100644
--- a/API/DTOs/UserPreferencesDto.cs
+++ b/API/DTOs/UserPreferencesDto.cs
@@ -9,61 +9,6 @@ namespace API.DTOs;
public sealed record UserPreferencesDto
{
- ///
- [Required]
- public ReadingDirection ReadingDirection { get; set; }
- ///
- [Required]
- public ScalingOption ScalingOption { get; set; }
- ///
- [Required]
- public PageSplitOption PageSplitOption { get; set; }
- ///
- [Required]
- public ReaderMode ReaderMode { get; set; }
- ///
- [Required]
- public LayoutMode LayoutMode { get; set; }
- ///
- [Required]
- public bool EmulateBook { get; set; }
- ///
- [Required]
- public string BackgroundColor { get; set; } = "#000000";
- ///
- [Required]
- public bool SwipeToPaginate { get; set; }
- ///
- [Required]
- public bool AutoCloseMenu { get; set; }
- ///
- [Required]
- public bool ShowScreenHints { get; set; } = true;
- ///
- [Required]
- public bool AllowAutomaticWebtoonReaderDetection { get; set; }
-
- ///
- [Required]
- public int BookReaderMargin { get; set; }
- ///
- [Required]
- public int BookReaderLineSpacing { get; set; }
- ///
- [Required]
- public int BookReaderFontSize { get; set; }
- ///
- [Required]
- public string BookReaderFontFamily { get; set; } = null!;
- ///
- [Required]
- public bool BookReaderTapToPaginate { get; set; }
- ///
- [Required]
- public ReadingDirection BookReaderReadingDirection { get; set; }
- ///
- [Required]
- public WritingStyle BookReaderWritingStyle { get; set; }
///
/// UI Site Global Setting: The UI theme the user should use.
@@ -72,15 +17,6 @@ public sealed record UserPreferencesDto
[Required]
public SiteThemeDto? Theme { get; set; }
- [Required] public string BookReaderThemeName { get; set; } = null!;
- ///
- [Required]
- public BookPageLayoutMode BookReaderLayoutMode { get; set; }
- ///
- [Required]
- public bool BookReaderImmersiveMode { get; set; } = false;
- ///
- [Required]
public PageLayoutMode GlobalPageLayoutMode { get; set; } = PageLayoutMode.Cards;
///
[Required]
@@ -101,16 +37,6 @@ public sealed record UserPreferencesDto
[Required]
public string Locale { get; set; }
- ///
- [Required]
- public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark;
- ///
- [Required]
- public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical;
- ///
- [Required]
- public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None;
-
///
public bool AniListScrobblingEnabled { get; set; }
///
diff --git a/API/DTOs/UserReadingProfileDto.cs b/API/DTOs/UserReadingProfileDto.cs
new file mode 100644
index 000000000..23f67ce4d
--- /dev/null
+++ b/API/DTOs/UserReadingProfileDto.cs
@@ -0,0 +1,129 @@
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using API.Entities;
+using API.Entities.Enums;
+using API.Entities.Enums.UserPreferences;
+
+namespace API.DTOs;
+
+public sealed record UserReadingProfileDto
+{
+
+ public int Id { get; set; }
+ public int UserId { get; init; }
+
+ public string Name { get; init; }
+ public ReadingProfileKind Kind { get; init; }
+
+ #region MangaReader
+
+ ///
+ [Required]
+ public ReadingDirection ReadingDirection { get; set; }
+
+ ///
+ [Required]
+ public ScalingOption ScalingOption { get; set; }
+
+ ///
+ [Required]
+ public PageSplitOption PageSplitOption { get; set; }
+
+ ///
+ [Required]
+ public ReaderMode ReaderMode { get; set; }
+
+ ///
+ [Required]
+ public bool AutoCloseMenu { get; set; }
+
+ ///
+ [Required]
+ public bool ShowScreenHints { get; set; } = true;
+
+ ///
+ [Required]
+ public bool EmulateBook { get; set; }
+
+ ///
+ [Required]
+ public LayoutMode LayoutMode { get; set; }
+
+ ///
+ [Required]
+ public string BackgroundColor { get; set; } = "#000000";
+
+ ///
+ [Required]
+ public bool SwipeToPaginate { get; set; }
+
+ ///
+ [Required]
+ public bool AllowAutomaticWebtoonReaderDetection { get; set; }
+
+ ///
+ public int? WidthOverride { get; set; }
+
+ #endregion
+
+ #region EpubReader
+
+ ///
+ [Required]
+ public int BookReaderMargin { get; set; }
+
+ ///
+ [Required]
+ public int BookReaderLineSpacing { get; set; }
+
+ ///
+ [Required]
+ public int BookReaderFontSize { get; set; }
+
+ ///
+ [Required]
+ public string BookReaderFontFamily { get; set; } = null!;
+
+ ///
+ [Required]
+ public bool BookReaderTapToPaginate { get; set; }
+
+ ///
+ [Required]
+ public ReadingDirection BookReaderReadingDirection { get; set; }
+
+ ///
+ [Required]
+ public WritingStyle BookReaderWritingStyle { get; set; }
+
+ ///
+ [Required]
+ public string BookReaderThemeName { get; set; } = null!;
+
+ ///
+ [Required]
+ public BookPageLayoutMode BookReaderLayoutMode { get; set; }
+
+ ///
+ [Required]
+ public bool BookReaderImmersiveMode { get; set; } = false;
+
+ #endregion
+
+ #region PdfReader
+
+ ///
+ [Required]
+ public PdfTheme PdfTheme { get; set; } = PdfTheme.Dark;
+
+ ///
+ [Required]
+ public PdfScrollMode PdfScrollMode { get; set; } = PdfScrollMode.Vertical;
+
+ ///
+ [Required]
+ public PdfSpreadMode PdfSpreadMode { get; set; } = PdfSpreadMode.None;
+
+ #endregion
+
+}
diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs
index ce35ba7ec..3bbf45e23 100644
--- a/API/Data/DataContext.cs
+++ b/API/Data/DataContext.cs
@@ -81,6 +81,7 @@ public sealed class DataContext : IdentityDbContext MetadataSettings { get; set; } = null!;
public DbSet MetadataFieldMapping { get; set; } = null!;
public DbSet AppUserChapterRating { get; set; } = null!;
+ public DbSet AppUserReadingProfiles { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)
{
@@ -256,6 +257,32 @@ public sealed class DataContext : IdentityDbContext()
.Property(b => b.EnableCoverImage)
.HasDefaultValue(true);
+
+ builder.Entity()
+ .Property(b => b.BookThemeName)
+ .HasDefaultValue("Dark");
+ builder.Entity()
+ .Property(b => b.BackgroundColor)
+ .HasDefaultValue("#000000");
+ builder.Entity()
+ .Property(b => b.BookReaderWritingStyle)
+ .HasDefaultValue(WritingStyle.Horizontal);
+ builder.Entity()
+ .Property(b => b.AllowAutomaticWebtoonReaderDetection)
+ .HasDefaultValue(true);
+
+ builder.Entity()
+ .Property(rp => rp.LibraryIds)
+ .HasConversion(
+ v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
+ v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List())
+ .HasColumnType("TEXT");
+ builder.Entity()
+ .Property(rp => rp.SeriesIds)
+ .HasConversion(
+ v => JsonSerializer.Serialize(v, JsonSerializerOptions.Default),
+ v => JsonSerializer.Deserialize>(v, JsonSerializerOptions.Default) ?? new List())
+ .HasColumnType("TEXT");
}
#nullable enable
diff --git a/API/Data/ManualMigrations/v0.8.7/ManualMigrateReadingProfiles.cs b/API/Data/ManualMigrations/v0.8.7/ManualMigrateReadingProfiles.cs
new file mode 100644
index 000000000..b2afde98a
--- /dev/null
+++ b/API/Data/ManualMigrations/v0.8.7/ManualMigrateReadingProfiles.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Threading.Tasks;
+using API.Entities;
+using API.Entities.Enums;
+using API.Entities.History;
+using API.Extensions;
+using API.Helpers.Builders;
+using Kavita.Common.EnvironmentInfo;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace API.Data.ManualMigrations;
+
+public static class ManualMigrateReadingProfiles
+{
+ public static async Task Migrate(DataContext context, ILogger logger)
+ {
+ if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateReadingProfiles"))
+ {
+ return;
+ }
+
+ logger.LogCritical("Running ManualMigrateReadingProfiles migration - Please be patient, this may take some time. This is not an error");
+
+ var users = await context.AppUser
+ .Include(u => u.UserPreferences)
+ .Include(u => u.ReadingProfiles)
+ .ToListAsync();
+
+ foreach (var user in users)
+ {
+ var readingProfile = new AppUserReadingProfile
+ {
+ Name = "Default",
+ NormalizedName = "Default".ToNormalized(),
+ Kind = ReadingProfileKind.Default,
+ LibraryIds = [],
+ SeriesIds = [],
+ BackgroundColor = user.UserPreferences.BackgroundColor,
+ EmulateBook = user.UserPreferences.EmulateBook,
+ AppUser = user,
+ PdfTheme = user.UserPreferences.PdfTheme,
+ ReaderMode = user.UserPreferences.ReaderMode,
+ ReadingDirection = user.UserPreferences.ReadingDirection,
+ ScalingOption = user.UserPreferences.ScalingOption,
+ LayoutMode = user.UserPreferences.LayoutMode,
+ WidthOverride = null,
+ AppUserId = user.Id,
+ AutoCloseMenu = user.UserPreferences.AutoCloseMenu,
+ BookReaderMargin = user.UserPreferences.BookReaderMargin,
+ PageSplitOption = user.UserPreferences.PageSplitOption,
+ BookThemeName = user.UserPreferences.BookThemeName,
+ PdfSpreadMode = user.UserPreferences.PdfSpreadMode,
+ PdfScrollMode = user.UserPreferences.PdfScrollMode,
+ SwipeToPaginate = user.UserPreferences.SwipeToPaginate,
+ BookReaderFontFamily = user.UserPreferences.BookReaderFontFamily,
+ BookReaderFontSize = user.UserPreferences.BookReaderFontSize,
+ BookReaderImmersiveMode = user.UserPreferences.BookReaderImmersiveMode,
+ BookReaderLayoutMode = user.UserPreferences.BookReaderLayoutMode,
+ BookReaderLineSpacing = user.UserPreferences.BookReaderLineSpacing,
+ BookReaderReadingDirection = user.UserPreferences.BookReaderReadingDirection,
+ BookReaderWritingStyle = user.UserPreferences.BookReaderWritingStyle,
+ AllowAutomaticWebtoonReaderDetection = user.UserPreferences.AllowAutomaticWebtoonReaderDetection,
+ BookReaderTapToPaginate = user.UserPreferences.BookReaderTapToPaginate,
+ ShowScreenHints = user.UserPreferences.ShowScreenHints,
+ };
+ user.ReadingProfiles.Add(readingProfile);
+ }
+
+ await context.SaveChangesAsync();
+
+ context.ManualMigrationHistory.Add(new ManualMigrationHistory
+ {
+ Name = "ManualMigrateReadingProfiles",
+ ProductVersion = BuildInfo.Version.ToString(),
+ RanAt = DateTime.UtcNow,
+ });
+ await context.SaveChangesAsync();
+
+
+ logger.LogCritical("Running ManualMigrateReadingProfiles migration - Completed. This is not an error");
+
+ }
+}
diff --git a/API/Data/Migrations/20250601200056_ReadingProfiles.Designer.cs b/API/Data/Migrations/20250601200056_ReadingProfiles.Designer.cs
new file mode 100644
index 000000000..762eae142
--- /dev/null
+++ b/API/Data/Migrations/20250601200056_ReadingProfiles.Designer.cs
@@ -0,0 +1,3698 @@
+//
+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("20250601200056_ReadingProfiles")]
+ partial class ReadingProfiles
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
+
+ modelBuilder.Entity("API.Entities.AppRole", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("AspNetRoles", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.AppUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRestriction")
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRestrictionIncludeUnknowns")
+ .HasColumnType("INTEGER");
+
+ b.Property("AniListAccessToken")
+ .HasColumnType("TEXT");
+
+ b.Property("ApiKey")
+ .HasColumnType("TEXT");
+
+ b.Property("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("TEXT");
+
+ b.Property("ConfirmationToken")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("EmailConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("HasRunScrobbleEventGeneration")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastActive")
+ .HasColumnType("TEXT");
+
+ b.Property("LastActiveUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("TEXT");
+
+ b.Property("MalAccessToken")
+ .HasColumnType("TEXT");
+
+ b.Property("MalUserName")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property("ScrobbleEventGenerationRan")
+ .HasColumnType("TEXT");
+
+ b.Property("SecurityStamp")
+ .HasColumnType("TEXT");
+
+ b.Property("TwoFactorEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("AspNetUsers", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("FileName")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Page")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserBookmark");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserChapterRating", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("HasBeenRated")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("REAL");
+
+ b.Property("Review")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("ChapterId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserChapterRating");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserCollection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRating")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("CoverImage")
+ .HasColumnType("TEXT");
+
+ b.Property("CoverImageLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastSyncUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("MissingSeriesFromSource")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("PrimaryColor")
+ .HasColumnType("TEXT");
+
+ b.Property("Promoted")
+ .HasColumnType("INTEGER");
+
+ b.Property("SecondaryColor")
+ .HasColumnType("TEXT");
+
+ b.Property("Source")
+ .HasColumnType("INTEGER");
+
+ b.Property("SourceUrl")
+ .HasColumnType("TEXT");
+
+ b.Property("Summary")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.Property("TotalSourceCount")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserCollection");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserDashboardStream", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsProvided")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property("SmartFilterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("StreamType")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(4);
+
+ b.Property("Visible")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SmartFilterId");
+
+ b.HasIndex("Visible");
+
+ b.ToTable("AppUserDashboardStream");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserExternalSource", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("ApiKey")
+ .HasColumnType("TEXT");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Host")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserExternalSource");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserOnDeckRemoval");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AllowAutomaticWebtoonReaderDetection")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(true);
+
+ b.Property("AniListScrobblingEnabled")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(true);
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("AutoCloseMenu")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackgroundColor")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("#000000");
+
+ b.Property("BlurUnreadSummaries")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderFontFamily")
+ .HasColumnType("TEXT");
+
+ b.Property("BookReaderFontSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderImmersiveMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLineSpacing")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderMargin")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderTapToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderWritingStyle")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("BookThemeName")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("Dark");
+
+ b.Property("CollapseSeriesRelationships")
+ .HasColumnType("INTEGER");
+
+ b.Property("EmulateBook")
+ .HasColumnType("INTEGER");
+
+ b.Property("GlobalPageLayoutMode")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("LayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("Locale")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("en");
+
+ b.Property("NoTransitions")
+ .HasColumnType("INTEGER");
+
+ b.Property("PageSplitOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfScrollMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfSpreadMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfTheme")
+ .HasColumnType("INTEGER");
+
+ b.Property("PromptForDownloadSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReaderMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("ScalingOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShareReviews")
+ .HasColumnType("INTEGER");
+
+ b.Property("ShowScreenHints")
+ .HasColumnType("INTEGER");
+
+ b.Property("SwipeToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("ThemeId")
+ .HasColumnType("INTEGER");
+
+ b.Property("WantToReadSync")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(true);
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId")
+ .IsUnique();
+
+ b.HasIndex("ThemeId");
+
+ b.ToTable("AppUserPreferences");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserProgress", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookScrollId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PagesRead")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("ChapterId");
+
+ 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("HasBeenRated")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("REAL");
+
+ b.Property("Review")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Tagline")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserRating");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserReadingProfile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AllowAutomaticWebtoonReaderDetection")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(true);
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("AutoCloseMenu")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackgroundColor")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("#000000");
+
+ b.Property("BookReaderFontFamily")
+ .HasColumnType("TEXT");
+
+ b.Property("BookReaderFontSize")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderImmersiveMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLineSpacing")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderMargin")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderTapToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderWritingStyle")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("BookThemeName")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("Dark");
+
+ b.Property("EmulateBook")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property("LayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("LibraryIds")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .HasColumnType("TEXT");
+
+ b.Property("PageSplitOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfScrollMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfSpreadMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfTheme")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReaderMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("ScalingOption")
+ .HasColumnType("INTEGER");
+
+ b.PrimitiveCollection("SeriesIds")
+ .HasColumnType("TEXT");
+
+ b.Property("ShowScreenHints")
+ .HasColumnType("INTEGER");
+
+ b.Property("SwipeToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("WidthOverride")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserReadingProfiles");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RoleId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserSideNavStream", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ExternalSourceId")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsProvided")
+ .HasColumnType("INTEGER");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property("SmartFilterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("StreamType")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(5);
+
+ b.Property("Visible")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SmartFilterId");
+
+ b.HasIndex("Visible");
+
+ b.ToTable("AppUserSideNavStream");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserSmartFilter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Filter")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserSmartFilter");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookScrollId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PageNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Title")
+ .HasColumnType("TEXT");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("ChapterId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserTableOfContent");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserWantToRead", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserWantToRead");
+ });
+
+ modelBuilder.Entity("API.Entities.Chapter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRating")
+ .HasColumnType("INTEGER");
+
+ b.Property("AgeRatingLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("AlternateCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("AlternateNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("AlternateSeries")
+ .HasColumnType("TEXT");
+
+ b.Property