diff --git a/API.Tests/Services/BookServiceTests.cs b/API.Tests/Services/BookServiceTests.cs
index 5848c74ba..6037e0715 100644
--- a/API.Tests/Services/BookServiceTests.cs
+++ b/API.Tests/Services/BookServiceTests.cs
@@ -1,10 +1,12 @@
using System.IO;
using System.IO.Abstractions;
+using System.Threading.Tasks;
using API.Entities.Enums;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
using Microsoft.Extensions.Logging;
using NSubstitute;
+using VersOne.Epub;
using Xunit;
namespace API.Tests.Services;
@@ -142,4 +144,19 @@ public class BookServiceTests
Assert.Equal(parserInfo.Title, comicInfo.Title);
Assert.Equal(parserInfo.Series, comicInfo.Title);
}
+
+ ///
+ /// Tests that the ./ rewrite hack works as expected
+ ///
+ [Fact]
+ public async Task ShouldBeAbleToLookUpImage()
+ {
+ var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService");
+ var filePath = Path.Join(testDirectory, "Relative Key Test File.epub");
+
+ var result = await _bookService.GetResourceAsync(filePath, "./images/titlepage800.png");
+
+ Assert.True(result.IsSuccess);
+ Assert.Equal("image/png", result.ContentType);
+ }
}
diff --git a/API.Tests/Services/CleanupServiceTests.cs b/API.Tests/Services/CleanupServiceTests.cs
index db310d957..ed082cbed 100644
--- a/API.Tests/Services/CleanupServiceTests.cs
+++ b/API.Tests/Services/CleanupServiceTests.cs
@@ -633,7 +633,7 @@ public class CleanupServiceTests(ITestOutputHelper outputHelper): AbstractDbTest
await readerService.MarkChaptersAsRead(user, s.Id, new List() {c});
await unitOfWork.CommitAsync();
- var chapter = await unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id);
+ var chapter = await unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id, 1);
await unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter);
Assert.NotNull(chapter);
@@ -644,7 +644,7 @@ public class CleanupServiceTests(ITestOutputHelper outputHelper): AbstractDbTest
unitOfWork.ChapterRepository.Update(c);
await unitOfWork.CommitAsync();
- chapter = await unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id);
+ chapter = await unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id, 1);
await unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter);
Assert.NotNull(chapter);
Assert.Equal(2, chapter.PagesRead);
@@ -655,7 +655,7 @@ public class CleanupServiceTests(ITestOutputHelper outputHelper): AbstractDbTest
new DirectoryService(Substitute.For>(), new MockFileSystem()));
await cleanupService.EnsureChapterProgressIsCapped();
- chapter = await unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id);
+ chapter = await unitOfWork.ChapterRepository.GetChapterDtoAsync(c.Id, 1);
await unitOfWork.ChapterRepository.AddChapterModifiers(user.Id, chapter);
Assert.NotNull(chapter);
diff --git a/API.Tests/Services/SiteThemeServiceTests.cs b/API.Tests/Services/SiteThemeServiceTests.cs
index c936b8d89..327d82060 100644
--- a/API.Tests/Services/SiteThemeServiceTests.cs
+++ b/API.Tests/Services/SiteThemeServiceTests.cs
@@ -33,7 +33,7 @@ public class SiteThemeServiceTest(ITestOutputHelper outputHelper): AbstractDbTes
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
var ds = new DirectoryService(Substitute.For>(), filesystem);
- var siteThemeService = new ThemeService(ds, unitOfWork, _messageHub, Substitute.For(),
+ var siteThemeService = new ThemeService(ds, unitOfWork, _messageHub,
Substitute.For>(), Substitute.For());
context.SiteTheme.Add(new SiteTheme()
@@ -62,7 +62,7 @@ public class SiteThemeServiceTest(ITestOutputHelper outputHelper): AbstractDbTes
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
var ds = new DirectoryService(Substitute.For>(), filesystem);
- var siteThemeService = new ThemeService(ds, unitOfWork, _messageHub, Substitute.For(),
+ var siteThemeService = new ThemeService(ds, unitOfWork, _messageHub,
Substitute.For>(), Substitute.For());
context.SiteTheme.Add(new SiteTheme()
@@ -91,7 +91,7 @@ public class SiteThemeServiceTest(ITestOutputHelper outputHelper): AbstractDbTes
var filesystem = CreateFileSystem();
filesystem.AddFile($"{SiteThemeDirectory}custom.css", new MockFileData("123"));
var ds = new DirectoryService(Substitute.For>(), filesystem);
- var siteThemeService = new ThemeService(ds, unitOfWork, _messageHub, Substitute.For(),
+ var siteThemeService = new ThemeService(ds, unitOfWork, _messageHub,
Substitute.For>(), Substitute.For());
context.SiteTheme.Add(new SiteTheme()
diff --git a/API.Tests/Services/Test Data/BookService/Relative Key Test File.epub b/API.Tests/Services/Test Data/BookService/Relative Key Test File.epub
new file mode 100644
index 000000000..2b58afec1
Binary files /dev/null and b/API.Tests/Services/Test Data/BookService/Relative Key Test File.epub differ
diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs
index 0b0a17160..2277f438c 100644
--- a/API/Controllers/AccountController.cs
+++ b/API/Controllers/AccountController.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
@@ -17,7 +16,6 @@ using API.Errors;
using API.Extensions;
using API.Helpers.Builders;
using API.Services;
-using API.Services.Plus;
using API.SignalR;
using AutoMapper;
using Hangfire;
@@ -29,7 +27,6 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
-using SharpCompress;
namespace API.Controllers;
@@ -53,7 +50,6 @@ public class AccountController : BaseApiController
private readonly IEmailService _emailService;
private readonly IEventHub _eventHub;
private readonly ILocalizationService _localizationService;
- private readonly IOidcService _oidcService;
///
public AccountController(UserManager userManager,
@@ -62,8 +58,7 @@ public class AccountController : BaseApiController
ILogger logger,
IMapper mapper, IAccountService accountService,
IEmailService emailService, IEventHub eventHub,
- ILocalizationService localizationService,
- IOidcService oidcService)
+ ILocalizationService localizationService)
{
_userManager = userManager;
_signInManager = signInManager;
@@ -75,7 +70,6 @@ public class AccountController : BaseApiController
_emailService = emailService;
_eventHub = eventHub;
_localizationService = localizationService;
- _oidcService = oidcService;
}
///
@@ -197,11 +191,7 @@ public class AccountController : BaseApiController
var result = await _userManager.CreateAsync(user, registerDto.Password);
if (!result.Succeeded) return BadRequest(result.Errors);
- // Assign default streams
- _accountService.AddDefaultStreamsToUser(user);
-
- // Assign default reading profile
- await _accountService.AddDefaultReadingProfileToUser(user);
+ await _accountService.SeedUser(user);
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
if (string.IsNullOrEmpty(token)) return BadRequest(await _localizationService.Get("en", "confirm-token-gen"));
@@ -534,6 +524,11 @@ public class AccountController : BaseApiController
return Ok();
}
+ ///
+ /// Change the Age Rating restriction for the user
+ ///
+ ///
+ ///
[HttpPost("update/age-restriction")]
public async Task UpdateAgeRestriction(UpdateAgeRestrictionDto dto)
{
@@ -745,11 +740,7 @@ public class AccountController : BaseApiController
var result = await _userManager.CreateAsync(user, AccountService.DefaultPassword);
if (!result.Succeeded) return BadRequest(result.Errors);
- // Assign default streams
- _accountService.AddDefaultStreamsToUser(user);
-
- // Assign default reading profile
- await _accountService.AddDefaultReadingProfileToUser(user);
+ await _accountService.SeedUser(user);
// Assign Roles
var roles = dto.Roles;
diff --git a/API/Controllers/AnnotationController.cs b/API/Controllers/AnnotationController.cs
index f6fcfbb87..5da784dc3 100644
--- a/API/Controllers/AnnotationController.cs
+++ b/API/Controllers/AnnotationController.cs
@@ -45,6 +45,17 @@ public class AnnotationController : BaseApiController
return Ok(await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapterId));
}
+ ///
+ /// Returns all annotations by Series
+ ///
+ ///
+ ///
+ [HttpGet("all-for-series")]
+ public async Task> GetAnnotationsBySeries(int seriesId)
+ {
+ return Ok(await _unitOfWork.UserRepository.GetAnnotationDtosBySeries(User.GetUserId(), seriesId));
+ }
+
///
/// Returns the Annotation by Id. User must have access to annotation.
///
diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs
index 2fcf8567a..c41ad4077 100644
--- a/API/Controllers/BookController.cs
+++ b/API/Controllers/BookController.cs
@@ -111,19 +111,16 @@ public class BookController : BaseApiController
public async Task GetBookPageResources(int chapterId, [FromQuery] string file)
{
if (chapterId <= 0) return BadRequest(await _localizationService.Get("en", "chapter-doesnt-exist"));
- var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
+
+ var chapter = await _cacheService.Ensure(chapterId);
if (chapter == null) return BadRequest(await _localizationService.Get("en", "chapter-doesnt-exist"));
- using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.LenientBookReaderOptions);
- var key = BookService.CoalesceKeyForAnyFile(book, file);
+ var cachedFilePath = Path.Join(_cacheService.GetCachePath(chapterId), Path.GetFileName(chapter.Files.ElementAt(0).FilePath));
+ var result = await _bookService.GetResourceAsync(cachedFilePath, file);
- if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) return BadRequest(await _localizationService.Get("en", "file-missing"));
+ if (!result.IsSuccess) return BadRequest(await _localizationService.Translate(User.GetUserId(), result.ErrorMessage));
- var bookFile = book.Content.AllFiles.GetLocalFileRefByKey(key);
- var content = await bookFile.ReadContentAsBytesAsync();
-
- var contentType = BookService.GetContentType(bookFile.ContentType);
- return File(content, contentType, $"{chapterId}-{file}");
+ return File(result.Content, result.ContentType, $"{chapterId}-{file}");
}
///
diff --git a/API/Controllers/ChapterController.cs b/API/Controllers/ChapterController.cs
index 94535d499..4ddbd86a0 100644
--- a/API/Controllers/ChapterController.cs
+++ b/API/Controllers/ChapterController.cs
@@ -51,9 +51,7 @@ public class ChapterController : BaseApiController
[HttpGet]
public async Task> GetChapter(int chapterId)
{
- var chapter =
- await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId,
- ChapterIncludes.People | ChapterIncludes.Files);
+ var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, User.GetUserId());
return Ok(chapter);
}
diff --git a/API/Controllers/ColorScapeController.cs b/API/Controllers/ColorScapeController.cs
index 04827658d..bc9293b7a 100644
--- a/API/Controllers/ColorScapeController.cs
+++ b/API/Controllers/ColorScapeController.cs
@@ -50,7 +50,7 @@ public class ColorScapeController : BaseApiController
[HttpGet("chapter")]
public async Task> GetColorScapeForChapter(int id)
{
- var entity = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(id);
+ var entity = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(id, User.GetUserId());
return GetColorSpaceDto(entity);
}
diff --git a/API/Controllers/FallbackController.cs b/API/Controllers/FallbackController.cs
index 0c925476f..9aff82027 100644
--- a/API/Controllers/FallbackController.cs
+++ b/API/Controllers/FallbackController.cs
@@ -20,8 +20,13 @@ public class FallbackController : Controller
_taskScheduler = taskScheduler;
}
- public PhysicalFileResult Index()
+ public IActionResult Index()
{
+ if (HttpContext.Request.Path.StartsWithSegments("/api"))
+ {
+ return NotFound();
+ }
+
return PhysicalFile(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "index.html"), "text/HTML");
}
}
diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs
index 0fd9c437a..e59620340 100644
--- a/API/Controllers/OPDSController.cs
+++ b/API/Controllers/OPDSController.cs
@@ -18,6 +18,7 @@ using API.DTOs.Filtering.v2;
using API.DTOs.OPDS;
using API.DTOs.Person;
using API.DTOs.Progress;
+using API.DTOs.ReadingLists;
using API.DTOs.Search;
using API.Entities;
using API.Entities.Enums;
@@ -105,33 +106,32 @@ public class OpdsController : BaseApiController
private readonly XmlSerializer _xmlSerializer;
private readonly XmlSerializer _xmlOpenSearchSerializer;
- private readonly FilterDto _filterDto = new FilterDto()
+ private readonly FilterDto _filterDto = new()
{
- Formats = new List(),
- Character = new List(),
- Colorist = new List(),
- Editor = new List(),
- Genres = new List(),
- Inker = new List(),
- Languages = new List(),
- Letterer = new List(),
- Penciller = new List(),
- Libraries = new List(),
- Publisher = new List(),
+ Formats = [],
+ Character = [],
+ Colorist = [],
+ Editor = [],
+ Genres = [],
+ Inker = [],
+ Languages = [],
+ Letterer = [],
+ Penciller = [],
+ Libraries = [],
+ Publisher = [],
Rating = 0,
- Tags = new List(),
- Translators = new List(),
- Writers = new List(),
- AgeRating = new List(),
- CollectionTags = new List(),
- CoverArtist = new List(),
+ Tags = [],
+ Translators = [],
+ Writers = [],
+ AgeRating = [],
+ CollectionTags = [],
+ CoverArtist = [],
ReadStatus = new ReadStatus(),
SortOptions = null,
- PublicationStatus = new List()
+ PublicationStatus = []
};
- private readonly FilterV2Dto _filterV2Dto = new FilterV2Dto();
- private readonly ChapterSortComparerDefaultLast _chapterSortComparerDefaultLast = ChapterSortComparerDefaultLast.Default;
+ private readonly FilterV2Dto _filterV2Dto = new();
private const int PageSize = 20;
public const string UserId = nameof(UserId);
@@ -187,10 +187,10 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-on-deck")
},
- Links = new List()
- {
+ Links =
+ [
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/on-deck"),
- }
+ ]
});
break;
case DashboardStreamType.NewlyAdded:
@@ -202,10 +202,10 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-recently-added")
},
- Links = new List()
- {
+ Links =
+ [
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-added"),
- }
+ ]
});
break;
case DashboardStreamType.RecentlyUpdated:
@@ -217,10 +217,10 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-recently-updated")
},
- Links = new List()
- {
+ Links =
+ [
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-updated"),
- }
+ ]
});
break;
case DashboardStreamType.MoreInGenre:
@@ -235,10 +235,10 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-more-in-genre", randomGenre.Title)
},
- Links = new List()
- {
+ Links =
+ [
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/more-in-genre?genreId={randomGenre.Id}"),
- }
+ ]
});
break;
case DashboardStreamType.SmartFilter:
@@ -269,10 +269,10 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-reading-lists")
},
- Links = new List()
- {
+ Links =
+ [
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list"),
- }
+ ]
});
feed.Entries.Add(new FeedEntry()
{
@@ -282,10 +282,10 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-want-to-read")
},
- Links = new List()
- {
+ Links =
+ [
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/want-to-read"),
- }
+ ]
});
feed.Entries.Add(new FeedEntry()
{
@@ -295,10 +295,10 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-libraries")
},
- Links = new List()
- {
+ Links =
+ [
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/libraries"),
- }
+ ]
});
feed.Entries.Add(new FeedEntry()
{
@@ -308,10 +308,10 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-collections")
},
- Links = new List()
- {
+ Links =
+ [
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections"),
- }
+ ]
});
if ((_unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId)).Any())
@@ -324,30 +324,13 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-smart-filters")
},
- Links = new List()
- {
+ Links =
+ [
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filters"),
- }
+ ]
});
}
- // if ((await _unitOfWork.AppUserExternalSourceRepository.GetExternalSources(userId)).Any())
- // {
- // feed.Entries.Add(new FeedEntry()
- // {
- // Id = "allExternalSources",
- // Title = await _localizationService.Translate(userId, "external-sources"),
- // Content = new FeedEntryContent()
- // {
- // Text = await _localizationService.Translate(userId, "browse-external-sources")
- // },
- // Links = new List()
- // {
- // CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/external-sources"),
- // }
- // });
- // }
-
return CreateXmlResult(SerializeXml(feed));
}
@@ -396,12 +379,12 @@ public class OpdsController : BaseApiController
[HttpGet("{apiKey}/smart-filters")]
[Produces("application/xml")]
- public async Task GetSmartFilters(string apiKey)
+ public async Task GetSmartFilters(string apiKey, [FromQuery] int pageNumber = 0)
{
var userId = GetUserIdFromContext();
var (_, prefix) = await GetPrefix();
- var filters = _unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId);
+ var filters = await _unitOfWork.AppUserSmartFilterRepository.GetPagedDtosByUserIdAsync(userId, GetUserParams(pageNumber));
var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters"), $"{apiKey}/smart-filters", apiKey, prefix);
SetFeedId(feed, "smartFilters");
@@ -419,40 +402,10 @@ public class OpdsController : BaseApiController
});
}
+ AddPagination(feed, filters, $"{prefix}{apiKey}/smart-filters");
return CreateXmlResult(SerializeXml(feed));
}
- [HttpGet("{apiKey}/external-sources")]
- [Produces("application/xml")]
- public async Task GetExternalSources(string apiKey)
- {
- // NOTE: This doesn't seem possible in OPDS v2.1 due to the resulting stream using relative links and most apps resolve against source url. Even using full paths doesn't work
- var userId = GetUserIdFromContext();
- var (_, prefix) = await GetPrefix();
-
- var externalSources = await _unitOfWork.AppUserExternalSourceRepository.GetExternalSources(userId);
- var feed = CreateFeed(await _localizationService.Translate(userId, "external-sources"), $"{apiKey}/external-sources", apiKey, prefix);
- SetFeedId(feed, "externalSources");
- foreach (var externalSource in externalSources)
- {
- var opdsUrl = $"{externalSource.Host}api/opds/{externalSource.ApiKey}";
- feed.Entries.Add(new FeedEntry()
- {
- Id = externalSource.Id.ToString(),
- Title = externalSource.Name,
- Summary = externalSource.Host,
- Links = new List()
- {
- CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, opdsUrl),
- CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{opdsUrl}/favicon")
- }
- });
- }
-
- return CreateXmlResult(SerializeXml(feed));
- }
-
-
[HttpGet("{apiKey}/libraries")]
[Produces("application/xml")]
public async Task GetLibraries(string apiKey)
@@ -463,7 +416,7 @@ public class OpdsController : BaseApiController
SetFeedId(feed, "libraries");
// Ensure libraries follow SideNav order
- var userSideNavStreams = await _unitOfWork.UserRepository.GetSideNavStreams(userId, false);
+ var userSideNavStreams = await _unitOfWork.UserRepository.GetSideNavStreams(userId);
foreach (var library in userSideNavStreams.Where(s => s.StreamType == SideNavStreamType.Library).Select(sideNavStream => sideNavStream.Library))
{
feed.Entries.Add(new FeedEntry()
@@ -506,14 +459,14 @@ public class OpdsController : BaseApiController
[HttpGet("{apiKey}/collections")]
[Produces("application/xml")]
- public async Task GetCollections(string apiKey)
+ public async Task GetCollections(string apiKey, [FromQuery] int pageNumber = 0)
{
var userId = GetUserIdFromContext();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null) return Unauthorized();
- var tags = await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(user.Id, true);
+ var tags = await _unitOfWork.CollectionTagRepository.GetCollectionDtosPagedAsync(user.Id, GetUserParams(pageNumber), true);
var (baseUrl, prefix) = await GetPrefix();
var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{apiKey}/collections", apiKey, prefix);
@@ -536,6 +489,7 @@ public class OpdsController : BaseApiController
]
}));
+ AddPagination(feed, tags, $"{prefix}{apiKey}/collections");
return CreateXmlResult(SerializeXml(feed));
}
@@ -609,14 +563,6 @@ public class OpdsController : BaseApiController
return CreateXmlResult(SerializeXml(feed));
}
- private static UserParams GetUserParams(int pageNumber)
- {
- return new UserParams()
- {
- PageNumber = pageNumber,
- PageSize = PageSize
- };
- }
[HttpGet("{apiKey}/reading-list/{readingListId}")]
[Produces("application/xml")]
@@ -645,13 +591,22 @@ public class OpdsController : BaseApiController
var feed = CreateFeed(readingList.Title + " " + await _localizationService.Translate(userId, "reading-list"), $"{apiKey}/reading-list/{readingListId}", apiKey, prefix);
SetFeedId(feed, $"reading-list-{readingListId}");
- var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId);
+
+ var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId, GetUserParams(pageNumber))).ToList();
+
+ // Check if there is reading progress or not, if so, inject a "continue-reading" item
+ var firstReadReadingListItem = items.FirstOrDefault(i => i.PagesRead > 0);
+ if (firstReadReadingListItem != null)
+ {
+ await AddContinueReadingPoint(apiKey, firstReadReadingListItem, userId, feed, prefix, baseUrl);
+ }
+
foreach (var item in items)
{
- var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(item.ChapterId);
+ var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(item.ChapterId, userId);
// If there is only one file underneath, add a direct acquisition link, otherwise add a subsection
- if (chapterDto != null && chapterDto.Files.Count == 1)
+ if (chapterDto is {Files.Count: 1})
{
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(item.SeriesId, userId);
feed.Entries.Add(await CreateChapterWithFile(userId, item.SeriesId, item.VolumeId, item.ChapterId,
@@ -668,6 +623,29 @@ public class OpdsController : BaseApiController
return CreateXmlResult(SerializeXml(feed));
}
+ private async Task AddContinueReadingPoint(string apiKey, int seriesId, ChapterDto chapterDto, int userId,
+ Feed feed, string prefix, string baseUrl)
+ {
+ var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
+ if (chapterDto is {Files.Count: 1})
+ {
+ feed.Entries.Add(await CreateContinueReadingFromFile(userId, seriesId, chapterDto.VolumeId, chapterDto.Id,
+ chapterDto.Files.First(), series!, chapterDto, apiKey, prefix, baseUrl));
+ }
+ }
+
+ private async Task AddContinueReadingPoint(string apiKey, ReadingListItemDto firstReadReadingListItem, int userId,
+ Feed feed, string prefix, string baseUrl)
+ {
+ var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(firstReadReadingListItem.ChapterId, userId);
+ var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(firstReadReadingListItem.SeriesId, userId);
+ if (chapterDto is {Files.Count: 1})
+ {
+ feed.Entries.Add(await CreateContinueReadingFromFile(userId, firstReadReadingListItem.SeriesId, firstReadReadingListItem.VolumeId, firstReadReadingListItem.ChapterId,
+ chapterDto.Files.First(), series!, chapterDto, apiKey, prefix, baseUrl));
+ }
+ }
+
[HttpGet("{apiKey}/libraries/{libraryId}")]
[Produces("application/xml")]
public async Task GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = 0)
@@ -684,14 +662,14 @@ public class OpdsController : BaseApiController
var filter = new FilterV2Dto
{
- Statements = new List() {
+ Statements = [
new ()
{
Comparison = FilterComparison.Equal,
Field = FilterField.Libraries,
Value = libraryId + string.Empty
}
- }
+ ]
};
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, GetUserParams(pageNumber), filter);
@@ -735,6 +713,7 @@ public class OpdsController : BaseApiController
var userId = GetUserIdFromContext();
var (baseUrl, prefix) = await GetPrefix();
var genre = await _unitOfWork.GenreRepository.GetGenreById(genreId);
+ if (genre == null) return BadRequest(await _localizationService.Translate(userId, "genre-doesnt-exist"));
var seriesDtos = await _unitOfWork.SeriesRepository.GetMoreIn(userId, 0, genreId, GetUserParams(pageNumber));
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(seriesDtos.Select(s => s.Id));
@@ -750,6 +729,13 @@ public class OpdsController : BaseApiController
return CreateXmlResult(SerializeXml(feed));
}
+ ///
+ /// Returns recently updated series. While pagination is avaible, total amount of pages is not due to implementation
+ /// details
+ ///
+ ///
+ ///
+ ///
[HttpGet("{apiKey}/recently-updated")]
[Produces("application/xml")]
public async Task GetRecentlyUpdated(string apiKey, [FromQuery] int pageNumber = 1)
@@ -832,7 +818,7 @@ public class OpdsController : BaseApiController
return BadRequest(await _localizationService.Translate(userId, "query-required"));
}
query = query.Replace(@"%", string.Empty);
- // Get libraries user has access to
+
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList();
if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(userId, "libraries-restricted"));
@@ -855,15 +841,15 @@ public class OpdsController : BaseApiController
Id = collection.Id.ToString(),
Title = collection.Title,
Summary = collection.Summary,
- Links = new List()
- {
+ Links =
+ [
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
$"{prefix}{apiKey}/collections/{collection.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
$"{baseUrl}api/image/collection-cover?collectionId={collection.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
$"{baseUrl}api/image/collection-cover?collectionId={collection.Id}&apiKey={apiKey}")
- }
+ ]
});
}
@@ -874,14 +860,14 @@ public class OpdsController : BaseApiController
Id = readingListDto.Id.ToString(),
Title = readingListDto.Title,
Summary = readingListDto.Summary,
- Links = new List()
- {
+ Links =
+ [
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"),
- }
+ ]
});
}
- // TODO: Search should allow Chapters/Files and more
+ feed.Total = feed.Entries.Count;
return CreateXmlResult(SerializeXml(feed));
}
@@ -926,20 +912,30 @@ public class OpdsController : BaseApiController
SetFeedId(feed, $"series-{series.Id}");
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}"));
+
+ // Check if there is reading progress or not, if so, inject a "continue-reading" item
+ var anyUserProgress = await _unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId);
+ if (anyUserProgress)
+ {
+ var chapterDto = await _readerService.GetContinuePoint(seriesId, userId);
+ await AddContinueReadingPoint(apiKey, seriesId, chapterDto, userId, feed, prefix, baseUrl);
+ }
+
+
var chapterDict = new Dictionary();
var fileDict = new Dictionary();
+
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
foreach (var volume in seriesDetail.Volumes)
{
- var chaptersForVolume = await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id, ChapterIncludes.Files | ChapterIncludes.People);
+ var chaptersForVolume = await _unitOfWork.ChapterRepository.GetChapterDtosAsync(volume.Id, userId);
- foreach (var chapter in chaptersForVolume)
+ foreach (var chapterDto in chaptersForVolume)
{
- var chapterId = chapter.Id;
+ var chapterId = chapterDto.Id;
if (!chapterDict.TryAdd(chapterId, 0)) continue;
- var chapterDto = _mapper.Map(chapter);
- foreach (var mangaFile in chapter.Files)
+ foreach (var mangaFile in chapterDto.Files)
{
// If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception
if (!fileDict.TryAdd(mangaFile.Id, 0)) continue;
@@ -999,25 +995,31 @@ public class OpdsController : BaseApiController
return NotFound();
}
- var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters);
if (volume == null)
{
return NotFound();
}
- var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s ",
+ var feed = CreateFeed($"{series.Name} - Volume {volume!.Name}",
$"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix);
- SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{_seriesService.FormatChapterName(userId, libraryType)}s");
+ SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}");
- foreach (var chapterId in volume.Chapters.Select(c => c.Id))
+ var chapterDtos = await _unitOfWork.ChapterRepository.GetChapterDtoByIdsAsync(volume.Chapters.Select(c => c.Id), userId);
+
+ // Check if there is reading progress or not, if so, inject a "continue-reading" item
+ var firstChapterWithProgress = chapterDtos.FirstOrDefault(c => c.PagesRead > 0);
+ if (firstChapterWithProgress != null)
{
- var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, ChapterIncludes.Files | ChapterIncludes.People);
- if (chapterDto == null) continue;
+ var chapterDto = await _readerService.GetContinuePoint(seriesId, userId);
+ await AddContinueReadingPoint(apiKey, seriesId, chapterDto, userId, feed, prefix, baseUrl);
+ }
+ foreach (var chapterDto in chapterDtos)
+ {
foreach (var mangaFile in chapterDto.Files)
{
- feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterId, mangaFile, series, chapterDto!, apiKey, prefix, baseUrl));
+ feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterDto.Id, mangaFile, series, chapterDto!, apiKey, prefix, baseUrl));
}
}
@@ -1032,14 +1034,17 @@ public class OpdsController : BaseApiController
var (baseUrl, prefix) = await GetPrefix();
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
+ if (series == null) return BadRequest(await _localizationService.Translate(userId, "series-doesnt-exist"));
+
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
- var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, ChapterIncludes.Files | ChapterIncludes.People);
+ var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, userId);
if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist"));
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
- var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s",
+ var chapterName = await _seriesService.FormatChapterName(userId, libraryType);
+ var feed = CreateFeed( $"{series.Name} - Volume {volume!.Name} - {chapterName} {chapterId}",
$"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey, prefix);
SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{_seriesService.FormatChapterName(userId, libraryType)}-{chapterId}-files");
@@ -1197,6 +1202,17 @@ public class OpdsController : BaseApiController
};
}
+ private async Task CreateContinueReadingFromFile(int userId, int seriesId, int volumeId, int chapterId,
+ MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl)
+ {
+ var entry = await CreateChapterWithFile(userId, seriesId, volumeId, chapterId, mangaFile, series, chapter,
+ apiKey, prefix, baseUrl);
+
+ entry.Title = await _localizationService.Translate(userId, "opds-continue-reading-title", entry.Title);
+
+ return entry;
+ }
+
private async Task CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId,
MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl)
{
@@ -1216,6 +1232,7 @@ public class OpdsController : BaseApiController
{
var volumeLabel = await _localizationService.Translate(userId, "volume-num", string.Empty);
SeriesService.RenameVolumeName(volume, libraryType, volumeLabel);
+
if (!volume.IsLooseLeaf())
{
title += $" - {volume.Name}";
@@ -1231,10 +1248,10 @@ public class OpdsController : BaseApiController
}
// Chunky requires a file at the end. Our API ignores this
- var accLink =
- CreateLink(FeedLinkRelation.Acquisition, fileType,
+ 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()
@@ -1269,6 +1286,9 @@ public class OpdsController : BaseApiController
entry.Links.Add(await CreatePageStreamLink(series.LibraryId, seriesId, volumeId, chapterId, mangaFile, apiKey, prefix));
}
+ // Patch in reading status on the item (as OPDS is seriously lacking)
+ entry.Title = $"{GetReadingProgressIcon(chapter.PagesRead, chapter.Pages)} {entry.Title}";
+
return entry;
}
@@ -1306,12 +1326,27 @@ public class OpdsController : BaseApiController
// Save progress for the user (except Panels, they will use a direct connection)
var userAgent = Request.Headers["User-Agent"].ToString();
+
+
+
if (!userAgent.StartsWith("Panels", StringComparison.InvariantCultureIgnoreCase) || !saveProgress)
{
+ // Kavita expects 0-N for progress, KOReader doesn't respect the OPDS-PS spec and does some wierd stuff
+ // https://github.com/Kareadita/Kavita/pull/4014#issuecomment-3313677492
+ var koreaderOffset = 0;
+ if (userAgent.StartsWith("Koreader", StringComparison.InvariantCultureIgnoreCase))
+ {
+ var totalPages = await _unitOfWork.ChapterRepository.GetChapterTotalPagesAsync(chapterId);
+ if (totalPages - pageNumber < 2)
+ {
+ koreaderOffset = 1;
+ }
+ }
+
await _readerService.SaveReadingProgress(new ProgressDto()
{
ChapterId = chapterId,
- PageNum = pageNumber,
+ PageNum = pageNumber + koreaderOffset,
SeriesId = seriesId,
VolumeId = volumeId,
LibraryId =libraryId
@@ -1322,7 +1357,7 @@ public class OpdsController : BaseApiController
}
catch (Exception)
{
- _cacheService.CleanupChapters(new []{ chapterId });
+ _cacheService.CleanupChapters([chapterId]);
throw;
}
}
@@ -1334,6 +1369,7 @@ public class OpdsController : BaseApiController
var userId = GetUserIdFromContext();
var files = _directoryService.GetFilesWithExtension(Path.Join(Directory.GetCurrentDirectory(), ".."), @"\.ico");
if (files.Length == 0) return BadRequest(await _localizationService.Translate(userId, "favicon-doesnt-exist"));
+
var path = files[0];
var content = await _directoryService.ReadFileAsync(path);
var format = Path.GetExtension(path);
@@ -1452,8 +1488,35 @@ public class OpdsController : BaseApiController
}
}
+ private static UserParams GetUserParams(int pageNumber)
+ {
+ return new UserParams()
+ {
+ PageNumber = pageNumber,
+ PageSize = PageSize
+ };
+ }
+
private static string RemoveInvalidXmlChars(string input)
{
return new string(input.Where(XmlConvert.IsXmlChar).ToArray());
}
+
+ private static string GetReadingProgressIcon(int pagesRead, int totalPages)
+ {
+ if (pagesRead == 0) return "⭘";
+
+ var percentageRead = (double)pagesRead / totalPages;
+
+ return percentageRead switch
+ {
+ // 100%
+ >= 1.0 => "⬤",
+ // > 50% and < 100%
+ > 0.5 => "◕",
+ // > 25% and <= 50%
+ > 0.25 => "◑",
+ _ => "◔"
+ };
+ }
}
diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs
index 09790c17b..6281c2888 100644
--- a/API/Controllers/ReaderController.cs
+++ b/API/Controllers/ReaderController.cs
@@ -516,7 +516,7 @@ public class ReaderController : BaseApiController
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
if (user == null) return Unauthorized();
- user.Progresses ??= new List();
+ user.Progresses ??= [];
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(dto.SeriesIds.ToArray(), true);
foreach (var volume in volumes)
@@ -566,9 +566,11 @@ public class ReaderController : BaseApiController
public async Task SaveProgress(ProgressDto progressDto)
{
var userId = User.GetUserId();
- if (!await _readerService.SaveReadingProgress(progressDto, userId))
- return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress"));
+ if (!await _readerService.SaveReadingProgress(progressDto, userId))
+ {
+ return BadRequest(await _localizationService.Translate(userId, "generic-read-progress"));
+ }
return Ok(true);
}
@@ -627,7 +629,7 @@ public class ReaderController : BaseApiController
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
if (user == null) return Unauthorized();
- if (user.Bookmarks == null) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
+ if (user.Bookmarks == null || user.Bookmarks.Count == 0) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
try
{
@@ -667,7 +669,7 @@ public class ReaderController : BaseApiController
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
if (user == null) return Unauthorized();
- if (user.Bookmarks == null) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
+ if (user.Bookmarks == null || user.Bookmarks.Count == 0) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
try
{
@@ -882,7 +884,7 @@ public class ReaderController : BaseApiController
{
var userId = User.GetUserId();
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
- var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
+ var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, userId);
if (series == null || chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
// Patch in the reading progress
diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs
index ae9c7ddd8..e33ecebe1 100644
--- a/API/Controllers/SeriesController.cs
+++ b/API/Controllers/SeriesController.cs
@@ -180,7 +180,7 @@ public class SeriesController : BaseApiController
[HttpGet("chapter")]
public async Task> GetChapter(int chapterId)
{
- var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
+ var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, User.GetUserId());
if (chapter == null) return NoContent();
return Ok(await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter));
}
diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs
index 5a9ad49f8..340116d18 100644
--- a/API/Controllers/SettingsController.cs
+++ b/API/Controllers/SettingsController.cs
@@ -71,6 +71,9 @@ public class SettingsController : BaseApiController
public async Task> GetSettings()
{
var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
+
+ // Do not send OIDC secret to user
+ settingsDto.OidcConfig.Secret = "*".Repeat(settingsDto.OidcConfig.Secret.Length);
return Ok(settingsDto);
}
diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs
index abf8468f0..f67b90a43 100644
--- a/API/Controllers/UsersController.cs
+++ b/API/Controllers/UsersController.cs
@@ -109,6 +109,7 @@ public class UsersController : BaseApiController
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
existingPreferences.ShareReviews = preferencesDto.ShareReviews;
+ existingPreferences.ColorScapeEnabled = preferencesDto.ColorScapeEnabled;
existingPreferences.BookReaderHighlightSlots = preferencesDto.BookReaderHighlightSlots;
if (await _licenseService.HasActiveLicense())
diff --git a/API/DTOs/OPDS/Feed.cs b/API/DTOs/OPDS/Feed.cs
index 5f4c4b115..e10465844 100644
--- a/API/DTOs/OPDS/Feed.cs
+++ b/API/DTOs/OPDS/Feed.cs
@@ -4,11 +4,6 @@ using System.Xml.Serialization;
namespace API.DTOs.OPDS;
-// TODO: OPDS Dtos are internal state, shouldn't be in DTO directory
-
-///
-///
-///
[XmlRoot("feed", Namespace = "http://www.w3.org/2005/Atom")]
public sealed record Feed
{
@@ -41,10 +36,10 @@ public sealed record Feed
public int? StartIndex { get; set; } = null;
[XmlElement("link")]
- public List Links { get; set; } = new List() ;
+ public List Links { get; set; } = [];
[XmlElement("entry")]
- public List Entries { get; set; } = new List();
+ public List Entries { get; set; } = [];
public bool ShouldSerializeTotal()
{
diff --git a/API/DTOs/Reader/AnnotationDto.cs b/API/DTOs/Reader/AnnotationDto.cs
index 911e3ab47..b73fa3baa 100644
--- a/API/DTOs/Reader/AnnotationDto.cs
+++ b/API/DTOs/Reader/AnnotationDto.cs
@@ -49,6 +49,7 @@ public sealed record AnnotationDto
///
public int SelectedSlotIndex { get; set; }
+
public required int ChapterId { get; set; }
public required int VolumeId { get; set; }
public required int SeriesId { get; set; }
diff --git a/API/DTOs/Reader/BookResourceResultDto.cs b/API/DTOs/Reader/BookResourceResultDto.cs
new file mode 100644
index 000000000..9935341d9
--- /dev/null
+++ b/API/DTOs/Reader/BookResourceResultDto.cs
@@ -0,0 +1,16 @@
+namespace API.DTOs.Reader;
+
+public sealed record BookResourceResultDto
+{
+ public bool IsSuccess { get; init; }
+ public string ErrorMessage { get; init; }
+ public byte[] Content { get; init; }
+ public string ContentType { get; init; }
+ public string FileName { get; init; }
+
+ public static BookResourceResultDto Success(byte[] content, string contentType, string fileName) =>
+ new() { IsSuccess = true, Content = content, ContentType = contentType, FileName = fileName };
+
+ public static BookResourceResultDto Error(string errorMessage) =>
+ new() { IsSuccess = false, ErrorMessage = errorMessage };
+}
diff --git a/API/DTOs/UserPreferencesDto.cs b/API/DTOs/UserPreferencesDto.cs
index b7fd625f4..f4c72bfe8 100644
--- a/API/DTOs/UserPreferencesDto.cs
+++ b/API/DTOs/UserPreferencesDto.cs
@@ -37,6 +37,9 @@ public sealed record UserPreferencesDto
///
[Required]
public string Locale { get; set; }
+ ///
+ [Required]
+ public bool ColorScapeEnabled { get; set; } = true;
///
public bool AniListScrobblingEnabled { get; set; }
diff --git a/API/Data/DataContext.cs b/API/Data/DataContext.cs
index a455cc32f..36a4526af 100644
--- a/API/Data/DataContext.cs
+++ b/API/Data/DataContext.cs
@@ -139,6 +139,9 @@ public sealed class DataContext : IdentityDbContext()
.Property(b => b.AllowAutomaticWebtoonReaderDetection)
.HasDefaultValue(true);
+ builder.Entity()
+ .Property(b => b.ColorScapeEnabled)
+ .HasDefaultValue(true);
builder.Entity()
.Property(b => b.AllowScrobbling)
diff --git a/API/Data/Migrations/20250919114119_ColorScapeSetting.Designer.cs b/API/Data/Migrations/20250919114119_ColorScapeSetting.Designer.cs
new file mode 100644
index 000000000..67c146f48
--- /dev/null
+++ b/API/Data/Migrations/20250919114119_ColorScapeSetting.Designer.cs
@@ -0,0 +1,3854 @@
+//
+using System;
+using System.Collections.Generic;
+using API.Data;
+using API.Entities.MetadataMatching;
+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("20250919114119_ColorScapeSetting")]
+ partial class ColorScapeSetting
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "9.0.7");
+
+ 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("IdentityProvider")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("LastActive")
+ .HasColumnType("TEXT");
+
+ b.Property("LastActiveUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LockoutEnabled")
+ .HasColumnType("INTEGER");
+
+ b.Property("LockoutEnd")
+ .HasColumnType("TEXT");
+
+ b.Property("MalAccessToken")
+ .HasColumnType("TEXT");
+
+ b.Property("MalUserName")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("OidcId")
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumber")
+ .HasColumnType("TEXT");
+
+ b.Property("PhoneNumberConfirmed")
+ .HasColumnType("INTEGER");
+
+ b.Property("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.AppUserAnnotation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property("ContainsSpoiler")
+ .HasColumnType("INTEGER");
+
+ b.Property("Context")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("EndingXPath")
+ .HasColumnType("TEXT");
+
+ b.Property("HighlightCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PageNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("SelectedSlotIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property("SelectedText")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.Property("XPath")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("ChapterId");
+
+ b.ToTable("AppUserAnnotation");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("FileName")
+ .HasColumnType("TEXT");
+
+ b.Property("ImageOffset")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Page")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.Property("XPath")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("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("BookReaderHighlightSlots")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("[]");
+
+ b.Property("BookReaderImmersiveMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderLineSpacing")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderMargin")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderTapToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookReaderWritingStyle")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(0);
+
+ b.Property("BookThemeName")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT")
+ .HasDefaultValue("Dark");
+
+ b.Property("CollapseSeriesRelationships")
+ .HasColumnType("INTEGER");
+
+ b.Property("ColorScapeEnabled")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(true);
+
+ b.Property("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("DisableWidthOverride")
+ .HasColumnType("INTEGER");
+
+ b.Property("EmulateBook")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property("LayoutMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("LibraryIds")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("NormalizedName")
+ .HasColumnType("TEXT");
+
+ b.Property("PageSplitOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfScrollMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfSpreadMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("PdfTheme")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReaderMode")
+ .HasColumnType("INTEGER");
+
+ b.Property("ReadingDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property("ScalingOption")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesIds")
+ .HasColumnType("TEXT");
+
+ b.Property("ShowScreenHints")
+ .HasColumnType("INTEGER");
+
+ b.Property("SwipeToPaginate")
+ .HasColumnType("INTEGER");
+
+ b.Property("WidthOverride")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserReadingProfiles");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("RoleId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserSideNavStream", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ExternalSourceId")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsProvided")
+ .HasColumnType("INTEGER");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property("SmartFilterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("StreamType")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER")
+ .HasDefaultValue(5);
+
+ b.Property("Visible")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("SmartFilterId");
+
+ b.HasIndex("Visible");
+
+ b.ToTable("AppUserSideNavStream");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserSmartFilter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Filter")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.ToTable("AppUserSmartFilter");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("BookScrollId")
+ .HasColumnType("TEXT");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PageNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("SelectedText")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property