diff --git a/API/API.csproj b/API/API.csproj
index f6be7c9ae..11b532bdb 100644
--- a/API/API.csproj
+++ b/API/API.csproj
@@ -81,7 +81,6 @@
-
diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs
index 2277f438c..beab49d95 100644
--- a/API/Controllers/AccountController.cs
+++ b/API/Controllers/AccountController.cs
@@ -102,16 +102,13 @@ public class AccountController : BaseApiController
try
{
- user.UpdateLastActive();
+ await _unitOfWork.UserRepository.UpdateUserAsActive(user.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName);
}
- _unitOfWork.UserRepository.Update(user);
- await _unitOfWork.CommitAsync();
-
return Ok(await ConstructUserDto(user, roles, false));
}
@@ -293,7 +290,7 @@ public class AccountController : BaseApiController
// Update LastActive on account
try
{
- user.UpdateLastActive();
+ await _unitOfWork.UserRepository.UpdateUserAsActive(user.Id);
}
catch (Exception ex)
{
diff --git a/API/Controllers/AnnotationController.cs b/API/Controllers/AnnotationController.cs
index 5da784dc3..6601087a0 100644
--- a/API/Controllers/AnnotationController.cs
+++ b/API/Controllers/AnnotationController.cs
@@ -1,14 +1,20 @@
-using System;
+#nullable enable
+using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
+using System.Text;
+using System.Text.Encodings.Web;
using System.Threading.Tasks;
using API.Data;
+using API.DTOs.Metadata.Browse.Requests;
using API.DTOs.Reader;
using API.Entities;
using API.Extensions;
using API.Helpers;
using API.Services;
using API.SignalR;
+using HtmlAgilityPack;
using Kavita.Common;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@@ -19,18 +25,35 @@ public class AnnotationController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger _logger;
- private readonly IBookService _bookService;
private readonly ILocalizationService _localizationService;
private readonly IEventHub _eventHub;
+ private readonly IAnnotationService _annotationService;
public AnnotationController(IUnitOfWork unitOfWork, ILogger logger,
- IBookService bookService, ILocalizationService localizationService, IEventHub eventHub)
+ ILocalizationService localizationService, IEventHub eventHub, IAnnotationService annotationService)
{
_unitOfWork = unitOfWork;
_logger = logger;
- _bookService = bookService;
_localizationService = localizationService;
_eventHub = eventHub;
+ _annotationService = annotationService;
+ }
+
+ ///
+ /// Returns a list of annotations for browsing
+ ///
+ ///
+ ///
+ ///
+ [HttpPost("all-filtered")]
+ public async Task>> GetAnnotationsForBrowse(BrowseAnnotationFilterDto filter, [FromQuery] UserParams? userParams)
+ {
+ userParams ??= UserParams.Default;
+
+ var list = await _unitOfWork.AnnotationRepository.GetAnnotationDtos(User.GetUserId(), filter, userParams);
+ Response.AddPaginationHeader(list.CurrentPage, list.PageSize, list.TotalCount, list.TotalPages);
+
+ return Ok(list);
}
///
@@ -41,7 +64,6 @@ public class AnnotationController : BaseApiController
[HttpGet("all")]
public async Task>> GetAnnotations(int chapterId)
{
-
return Ok(await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapterId));
}
@@ -77,62 +99,16 @@ public class AnnotationController : BaseApiController
{
try
{
- if (dto.HighlightCount == 0 || string.IsNullOrWhiteSpace(dto.SelectedText))
- {
- return BadRequest(_localizationService.Translate(User.GetUserId(), "invalid-payload"));
- }
-
- var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(dto.ChapterId);
- if (chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "chapter-doesnt-exist"));
-
- var chapterTitle = string.Empty;
- try
- {
- var toc = await _bookService.GenerateTableOfContents(chapter);
- var pageTocs = BookChapterItemHelper.GetTocForPage(toc, dto.PageNumber);
- if (pageTocs.Count > 0)
- {
- chapterTitle = pageTocs[0].Title;
- }
- }
- catch (KavitaException)
- {
- /* Swallow */
- }
-
- var annotation = new AppUserAnnotation()
- {
- XPath = dto.XPath,
- EndingXPath = dto.EndingXPath,
- ChapterId = dto.ChapterId,
- SeriesId = dto.SeriesId,
- VolumeId = dto.VolumeId,
- LibraryId = dto.LibraryId,
- HighlightCount = dto.HighlightCount,
- SelectedText = dto.SelectedText,
- Comment = dto.Comment,
- ContainsSpoiler = dto.ContainsSpoiler,
- PageNumber = dto.PageNumber,
- SelectedSlotIndex = dto.SelectedSlotIndex,
- AppUserId = User.GetUserId(),
- Context = dto.Context,
- ChapterTitle = chapterTitle
- };
-
- _unitOfWork.AnnotationRepository.Attach(annotation);
- await _unitOfWork.CommitAsync();
-
- return Ok(await _unitOfWork.AnnotationRepository.GetAnnotationDto(annotation.Id));
+ return Ok(await _annotationService.CreateAnnotation(User.GetUserId(), dto));
}
- catch (Exception ex)
+ catch (KavitaException ex)
{
- _logger.LogError(ex, "There was an exception when creating an annotation on {ChapterId} - Page {Page}", dto.ChapterId, dto.PageNumber);
- return BadRequest(_localizationService.Translate(User.GetUserId(), "annotation-failed-create"));
+ return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message));
}
}
///
- /// Update the modifable fields (Spoiler, highlight slot, and comment) for an annotation
+ /// Update the modifiable fields (Spoiler, highlight slot, and comment) for an annotation
///
///
///
@@ -141,28 +117,12 @@ public class AnnotationController : BaseApiController
{
try
{
- var annotation = await _unitOfWork.AnnotationRepository.GetAnnotation(dto.Id);
- if (annotation == null || annotation.AppUserId != User.GetUserId()) return BadRequest();
-
- annotation.ContainsSpoiler = dto.ContainsSpoiler;
- annotation.SelectedSlotIndex = dto.SelectedSlotIndex;
- annotation.Comment = dto.Comment;
- _unitOfWork.AnnotationRepository.Update(annotation);
-
- if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync())
- {
- await _eventHub.SendMessageToAsync(MessageFactory.AnnotationUpdate, MessageFactory.AnnotationUpdateEvent(dto),
- User.GetUserId());
- return Ok(dto);
- }
+ return Ok(await _annotationService.UpdateAnnotation(User.GetUserId(), dto));
}
- catch (Exception ex)
+ catch (KavitaException ex)
{
- _logger.LogError(ex, "There was an exception updating Annotation for Chapter {ChapterId} - Page {PageNumber}", dto.ChapterId, dto.PageNumber);
- return BadRequest();
+ return BadRequest(await _localizationService.Translate(User.GetUserId(), ex.Message));
}
-
- return Ok();
}
///
@@ -174,10 +134,75 @@ public class AnnotationController : BaseApiController
public async Task DeleteAnnotation(int annotationId)
{
var annotation = await _unitOfWork.AnnotationRepository.GetAnnotation(annotationId);
- if (annotation == null || annotation.AppUserId != User.GetUserId()) return BadRequest(_localizationService.Translate(User.GetUserId(), "annotation-delete"));
+ if (annotation == null || annotation.AppUserId != User.GetUserId()) return BadRequest(await _localizationService.Translate(User.GetUserId(), "annotation-delete"));
_unitOfWork.AnnotationRepository.Remove(annotation);
await _unitOfWork.CommitAsync();
+
return Ok();
}
+
+ ///
+ /// Removes annotations in bulk. Requires every annotation to be owned by the authenticated user
+ ///
+ ///
+ ///
+ [HttpPost("bulk-delete")]
+ public async Task DeleteAnnotationsBulk(IList annotationIds)
+ {
+ var userId = User.GetUserId();
+
+ var annotations = await _unitOfWork.AnnotationRepository.GetAnnotations(annotationIds);
+ if (annotations.Any(a => a.AppUserId != userId))
+ {
+ return BadRequest();
+ }
+
+ _unitOfWork.AnnotationRepository.Remove(annotations);
+ await _unitOfWork.CommitAsync();
+
+ return Ok();
+ }
+
+ ///
+ /// Exports annotations for the given users
+ ///
+ ///
+ [HttpPost("export-filter")]
+ public async Task ExportAnnotationsFilter(BrowseAnnotationFilterDto filter, [FromQuery] UserParams? userParams)
+ {
+ userParams ??= UserParams.Default;
+
+ var list = await _unitOfWork.AnnotationRepository.GetAnnotationDtos(User.GetUserId(), filter, userParams);
+ var annotations = list.Select(a => a.Id).ToList();
+
+ var json = await _annotationService.ExportAnnotations(User.GetUserId(), annotations);
+ if (string.IsNullOrEmpty(json)) return BadRequest();
+
+ var bytes = Encoding.UTF8.GetBytes(json);
+ var fileName = System.Web.HttpUtility.UrlEncode($"annotations_export_{User.GetUserId()}_{DateTime.UtcNow:yyyyMMdd_HHmmss}_filtered");
+ return File(bytes, "application/json", fileName + ".json");
+ }
+
+ ///
+ /// Exports Annotations for the User
+ ///
+ /// Export annotations with the given ids
+ ///
+ [HttpPost("export")]
+ public async Task ExportAnnotations(IList? annotations = null)
+ {
+ var json = await _annotationService.ExportAnnotations(User.GetUserId(), annotations);
+ if (string.IsNullOrEmpty(json)) return BadRequest();
+
+ var bytes = Encoding.UTF8.GetBytes(json);
+
+ var fileName = System.Web.HttpUtility.UrlEncode($"annotations_export_{User.GetUserId()}_{DateTime.UtcNow:yyyyMMdd_HHmmss}");
+ if (annotations != null)
+ {
+ fileName += "_user_selection";
+ }
+
+ return File(bytes, "application/json", fileName + ".json");
+ }
}
diff --git a/API/Controllers/LicenseController.cs b/API/Controllers/LicenseController.cs
index 30ed68771..a8e351467 100644
--- a/API/Controllers/LicenseController.cs
+++ b/API/Controllers/LicenseController.cs
@@ -50,7 +50,7 @@ public class LicenseController(
}
///
- /// Has any license registered with the instance. Does not check Kavita+ API
+ /// Has any license registered with the instance. Does not validate against Kavita+ API
///
///
[Authorize("RequireAdminRole")]
@@ -117,6 +117,16 @@ public class LicenseController(
return BadRequest(localizationService.Translate(User.GetUserId(), "unable-to-reset-k+"));
}
+ ///
+ /// Resend the welcome email to the user
+ ///
+ ///
+ [HttpPost("resend-license")]
+ public async Task> ResendWelcomeEmail()
+ {
+ return Ok(await licenseService.ResendWelcomeEmail());
+ }
+
///
/// Updates server license
///
diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs
index e9b930f07..267dbb7af 100644
--- a/API/Controllers/OPDSController.cs
+++ b/API/Controllers/OPDSController.cs
@@ -1,158 +1,57 @@
using System;
-using System.Collections.Generic;
-using System.Globalization;
using System.IO;
-using System.Linq;
-using System.Net;
using System.Threading.Tasks;
-using System.Xml;
using System.Xml.Serialization;
-using API.Comparators;
using API.Data;
-using API.Data.Repositories;
-using API.DTOs;
-using API.DTOs.Collection;
-using API.DTOs.CollectionTags;
-using API.DTOs.Filtering;
-using API.DTOs.Filtering.v2;
using API.DTOs.OPDS;
-using API.DTOs.Person;
+using API.DTOs.OPDS.Requests;
using API.DTOs.Progress;
-using API.DTOs.ReadingLists;
-using API.DTOs.Search;
-using API.Entities;
using API.Entities.Enums;
+using API.Exceptions;
using API.Extensions;
-using API.Helpers;
+using API.Middleware;
using API.Services;
-using API.Services.Tasks.Scanner.Parser;
-using AutoMapper;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.Filters;
-using Microsoft.Extensions.Logging;
using MimeTypes;
namespace API.Controllers;
-
#nullable enable
-/**
- * Middleware that checks if Opds has been enabled for this server, and sets OpdsController.UserId in HttpContext
- */
-[AttributeUsage(AttributeTargets.Class)]
-public class OpdsActionFilterAttribute(IUnitOfWork unitOfWork, ILocalizationService localizationService, ILogger logger): ActionFilterAttribute
-{
- public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
- {
- int userId;
- try
- {
- if (!context.ActionArguments.TryGetValue("apiKey", out var apiKeyObj) || apiKeyObj is not string apiKey)
- {
- context.Result = new BadRequestResult();
- return;
- }
-
- userId = await unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
- if (userId == null || userId == 0)
- {
- context.Result = new UnauthorizedResult();
- return;
- }
-
- var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
- if (!settings.EnableOpds)
- {
- context.Result = new ContentResult
- {
- Content = await localizationService.Translate(userId, "opds-disabled"),
- ContentType = "text/plain",
- StatusCode = (int)HttpStatusCode.BadRequest,
- };
- return;
- }
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "failed to handle OPDS request");
- context.Result = new BadRequestResult();
- return;
- }
-
- context.HttpContext.Items.Add(OpdsController.UserId, userId);
- await next();
- }
-
-}
[AllowAnonymous]
[ServiceFilter(typeof(OpdsActionFilterAttribute))]
+[ServiceFilter(typeof(OpdsActiveUserMiddlewareAttribute))]
public class OpdsController : BaseApiController
{
- private readonly ILogger _logger;
+ private readonly IOpdsService _opdsService;
private readonly IUnitOfWork _unitOfWork;
private readonly IDownloadService _downloadService;
private readonly IDirectoryService _directoryService;
private readonly ICacheService _cacheService;
private readonly IReaderService _readerService;
- private readonly ISeriesService _seriesService;
private readonly IAccountService _accountService;
private readonly ILocalizationService _localizationService;
- private readonly IMapper _mapper;
-
-
- private readonly XmlSerializer _xmlSerializer;
private readonly XmlSerializer _xmlOpenSearchSerializer;
- private readonly FilterDto _filterDto = new()
- {
- Formats = [],
- Character = [],
- Colorist = [],
- Editor = [],
- Genres = [],
- Inker = [],
- Languages = [],
- Letterer = [],
- Penciller = [],
- Libraries = [],
- Publisher = [],
- Rating = 0,
- Tags = [],
- Translators = [],
- Writers = [],
- AgeRating = [],
- CollectionTags = [],
- CoverArtist = [],
- ReadStatus = new ReadStatus(),
- SortOptions = null,
- PublicationStatus = []
- };
- private readonly FilterV2Dto _filterV2Dto = new();
- private const int PageSize = 20;
public const string UserId = nameof(UserId);
public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService,
IDirectoryService directoryService, ICacheService cacheService,
- IReaderService readerService, ISeriesService seriesService,
- IAccountService accountService, ILocalizationService localizationService,
- IMapper mapper, ILogger logger)
+ IReaderService readerService, IAccountService accountService,
+ ILocalizationService localizationService, IOpdsService opdsService)
{
_unitOfWork = unitOfWork;
_downloadService = downloadService;
_directoryService = directoryService;
_cacheService = cacheService;
_readerService = readerService;
- _seriesService = seriesService;
_accountService = accountService;
_localizationService = localizationService;
- _mapper = mapper;
- _logger = logger;
+ _opdsService = opdsService;
- _xmlSerializer = new XmlSerializer(typeof(Feed));
_xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription));
}
@@ -161,177 +60,28 @@ public class OpdsController : BaseApiController
return (int) HttpContext.Items[UserId]!;
}
+ ///
+ /// Returns the Catalogue for Kavita's OPDS Service
+ ///
+ ///
+ ///
[HttpPost("{apiKey}")]
[HttpGet("{apiKey}")]
[Produces("application/xml")]
public async Task Get(string apiKey)
{
- var userId = GetUserIdFromContext();
- var (_, prefix) = await GetPrefix();
+ var (baseUrl, prefix) = await GetPrefix();
- var feed = CreateFeed("Kavita", string.Empty, apiKey, prefix);
- SetFeedId(feed, "root");
-
- // Get the user's customized dashboard
- var streams = await _unitOfWork.UserRepository.GetDashboardStreams(userId, true);
- foreach (var stream in streams)
+ var feed = await _opdsService.GetCatalogue(new OpdsCatalogueRequest
{
- switch (stream.StreamType)
- {
- case DashboardStreamType.OnDeck:
- feed.Entries.Add(new FeedEntry()
- {
- Id = "onDeck",
- Title = await _localizationService.Translate(userId, "on-deck"),
- Content = new FeedEntryContent()
- {
- Text = await _localizationService.Translate(userId, "browse-on-deck")
- },
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/on-deck"),
- ]
- });
- break;
- case DashboardStreamType.NewlyAdded:
- feed.Entries.Add(new FeedEntry()
- {
- Id = "recentlyAdded",
- Title = await _localizationService.Translate(userId, "recently-added"),
- Content = new FeedEntryContent()
- {
- Text = await _localizationService.Translate(userId, "browse-recently-added")
- },
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-added"),
- ]
- });
- break;
- case DashboardStreamType.RecentlyUpdated:
- feed.Entries.Add(new FeedEntry()
- {
- Id = "recentlyUpdated",
- Title = await _localizationService.Translate(userId, "recently-updated"),
- Content = new FeedEntryContent()
- {
- Text = await _localizationService.Translate(userId, "browse-recently-updated")
- },
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-updated"),
- ]
- });
- break;
- case DashboardStreamType.MoreInGenre:
- var randomGenre = await _unitOfWork.GenreRepository.GetRandomGenre();
- if (randomGenre == null) break;
-
- feed.Entries.Add(new FeedEntry()
- {
- Id = "moreInGenre",
- Title = await _localizationService.Translate(userId, "more-in-genre", randomGenre.Title),
- Content = new FeedEntryContent()
- {
- Text = await _localizationService.Translate(userId, "browse-more-in-genre", randomGenre.Title)
- },
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/more-in-genre?genreId={randomGenre.Id}"),
- ]
- });
- break;
- case DashboardStreamType.SmartFilter:
-
- feed.Entries.Add(new FeedEntry()
- {
- Id = "smartFilter-" + stream.Id,
- Title = stream.Name,
- Content = new FeedEntryContent()
- {
- Text = stream.Name
- },
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
- $"{prefix}{apiKey}/smart-filters/{stream.SmartFilterId}/")
- ]
- });
- break;
- }
- }
-
- feed.Entries.Add(new FeedEntry()
- {
- Id = "readingList",
- Title = await _localizationService.Translate(userId, "reading-lists"),
- Content = new FeedEntryContent()
- {
- Text = await _localizationService.Translate(userId, "browse-reading-lists")
- },
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list"),
- ]
- });
- feed.Entries.Add(new FeedEntry()
- {
- Id = "wantToRead",
- Title = await _localizationService.Translate(userId, "want-to-read"),
- Content = new FeedEntryContent()
- {
- Text = await _localizationService.Translate(userId, "browse-want-to-read")
- },
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/want-to-read"),
- ]
- });
- feed.Entries.Add(new FeedEntry()
- {
- Id = "allLibraries",
- Title = await _localizationService.Translate(userId, "libraries"),
- Content = new FeedEntryContent()
- {
- Text = await _localizationService.Translate(userId, "browse-libraries")
- },
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/libraries"),
- ]
- });
- feed.Entries.Add(new FeedEntry()
- {
- Id = "allCollections",
- Title = await _localizationService.Translate(userId, "collections"),
- Content = new FeedEntryContent()
- {
- Text = await _localizationService.Translate(userId, "browse-collections")
- },
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections"),
- ]
+ ApiKey = apiKey,
+ Prefix = prefix,
+ BaseUrl = baseUrl,
+ UserId = GetUserIdFromContext()
});
- if ((_unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId)).Any())
- {
- feed.Entries.Add(new FeedEntry()
- {
- Id = "allSmartFilters",
- Title = await _localizationService.Translate(userId, "smart-filters"),
- Content = new FeedEntryContent()
- {
- Text = await _localizationService.Translate(userId, "browse-smart-filters")
- },
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filters"),
- ]
- });
- }
- return CreateXmlResult(SerializeXml(feed));
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
}
private async Task> GetPrefix()
@@ -348,394 +98,350 @@ public class OpdsController : BaseApiController
}
///
- /// Returns the Series matching this smart filter. If FromDashboard, will only return 20 records.
+ /// Get the User's Smart Filter series - Supports Pagination
///
///
[HttpGet("{apiKey}/smart-filters/{filterId}")]
[Produces("application/xml")]
- public async Task GetSmartFilter(string apiKey, int filterId, [FromQuery] int pageNumber = 0)
+ public async Task GetSmartFilter(string apiKey, int filterId, [FromQuery] int pageNumber = 1)
{
var userId = GetUserIdFromContext();
var (baseUrl, prefix) = await GetPrefix();
- var filter = await _unitOfWork.AppUserSmartFilterRepository.GetById(filterId);
- if (filter == null) return BadRequest(_localizationService.Translate(userId, "smart-filter-doesnt-exist"));
- var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters-" + filter.Id), $"{apiKey}/smart-filters/{filter.Id}/", apiKey, prefix);
- SetFeedId(feed, "smartFilters-" + filter.Id);
-
- var decodedFilter = SmartFilterHelper.Decode(filter.Filter);
- var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, GetUserParams(pageNumber),
- decodedFilter);
- var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id));
-
- foreach (var seriesDto in series)
+ var feed = await _opdsService.GetSeriesFromSmartFilter(new OpdsItemsFromEntityIdRequest()
{
- feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl));
- }
+ ApiKey = apiKey,
+ Prefix = prefix,
+ BaseUrl = baseUrl,
+ EntityId = filterId,
+ UserId = userId,
+ PageNumber = pageNumber
+ });
- AddPagination(feed, series, $"{prefix}{apiKey}/smart-filters/{filterId}/");
- return CreateXmlResult(SerializeXml(feed));
+
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
}
+ ///
+ /// Get the User's Smart Filters (Dashboard Context) - Supports Pagination
+ ///
+ ///
+ ///
+ ///
[HttpGet("{apiKey}/smart-filters")]
[Produces("application/xml")]
- public async Task GetSmartFilters(string apiKey, [FromQuery] int pageNumber = 0)
+ public async Task GetSmartFilters(string apiKey, [FromQuery] int pageNumber = 1)
{
- var userId = GetUserIdFromContext();
- var (_, prefix) = await GetPrefix();
-
- 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");
-
- foreach (var filter in filters)
+ try
{
- feed.Entries.Add(new FeedEntry()
- {
- Id = filter.Id.ToString(),
- Title = filter.Name,
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
- $"{prefix}{apiKey}/smart-filters/{filter.Id}")
- ]
- });
- }
+ var userId = GetUserIdFromContext();
+ var (baseUrl, prefix) = await GetPrefix();
- AddPagination(feed, filters, $"{prefix}{apiKey}/smart-filters");
- return CreateXmlResult(SerializeXml(feed));
+ var feed = await _opdsService.GetSmartFilters(new OpdsPaginatedCatalogueRequest()
+ {
+ BaseUrl = baseUrl,
+ Prefix = prefix,
+ UserId = userId,
+ ApiKey = apiKey,
+ PageNumber = pageNumber
+ });
+
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
+ }
+ catch (OpdsException ex)
+ {
+ return BadRequest(ex.Message);
+ }
}
+ ///
+ /// Get the User's Libraries - No Pagination Support
+ ///
+ ///
+ ///
+ ///
[HttpGet("{apiKey}/libraries")]
[Produces("application/xml")]
- public async Task GetLibraries(string apiKey)
+ public async Task GetLibraries(string apiKey, [FromQuery] int pageNumber = 1)
{
- var userId = GetUserIdFromContext();
- var (baseUrl, prefix) = await GetPrefix();
- var feed = CreateFeed(await _localizationService.Translate(userId, "libraries"), $"{apiKey}/libraries", apiKey, prefix);
- SetFeedId(feed, "libraries");
-
- // Ensure libraries follow SideNav order
- var userSideNavStreams = await _unitOfWork.UserRepository.GetSideNavStreams(userId);
- foreach (var library in userSideNavStreams.Where(s => s.StreamType == SideNavStreamType.Library)
- .Select(sideNavStream => sideNavStream.Library))
+ try
{
- feed.Entries.Add(new FeedEntry()
- {
- Id = library!.Id.ToString(),
- Title = library.Name!,
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
- $"{prefix}{apiKey}/libraries/{library.Id}"),
- CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
- $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}"),
- CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
- $"{baseUrl}api/image/library-cover?libraryId={library.Id}&apiKey={apiKey}")
- ]
- });
- }
+ var (baseUrl, prefix) = await GetPrefix();
- return CreateXmlResult(SerializeXml(feed));
+ var feed = await _opdsService.GetLibraries(new OpdsPaginatedCatalogueRequest()
+ {
+ BaseUrl = baseUrl,
+ Prefix = prefix,
+ UserId = GetUserIdFromContext(),
+ ApiKey = apiKey,
+ PageNumber = pageNumber
+ });
+
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
+ }
+ catch (OpdsException ex)
+ {
+ return BadRequest(ex.Message);
+ }
}
+ ///
+ /// Get the User's Want to Read list - Supports Pagination
+ ///
+ ///
+ ///
+ ///
[HttpGet("{apiKey}/want-to-read")]
[Produces("application/xml")]
- public async Task GetWantToRead(string apiKey, [FromQuery] int pageNumber = 0)
+ public async Task GetWantToRead(string apiKey, [FromQuery] int pageNumber = 1)
{
- var userId = GetUserIdFromContext();
- var (baseUrl, prefix) = await GetPrefix();
- var wantToReadSeries = await _unitOfWork.SeriesRepository.GetWantToReadForUserV2Async(userId, GetUserParams(pageNumber), _filterV2Dto);
- var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(wantToReadSeries.Select(s => s.Id));
+ try
+ {
+ var (baseUrl, prefix) = await GetPrefix();
- var feed = CreateFeed(await _localizationService.Translate(userId, "want-to-read"), $"{apiKey}/want-to-read", apiKey, prefix);
- SetFeedId(feed, $"want-to-read");
- AddPagination(feed, wantToReadSeries, $"{prefix}{apiKey}/want-to-read");
+ var feed = await _opdsService.GetWantToRead(new OpdsPaginatedCatalogueRequest()
+ {
+ BaseUrl = baseUrl,
+ Prefix = prefix,
+ UserId = GetUserIdFromContext(),
+ ApiKey = apiKey,
+ PageNumber = pageNumber
+ });
- feed.Entries.AddRange(wantToReadSeries.Select(seriesDto =>
- CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)));
-
- return CreateXmlResult(SerializeXml(feed));
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
+ }
+ catch (OpdsException ex)
+ {
+ return BadRequest(ex.Message);
+ }
}
+ ///
+ /// Get all Collections - Supports Pagination
+ ///
+ ///
+ ///
+ ///
[HttpGet("{apiKey}/collections")]
[Produces("application/xml")]
- public async Task GetCollections(string apiKey, [FromQuery] int pageNumber = 0)
+ public async Task GetCollections(string apiKey, [FromQuery] int pageNumber = 1)
{
- var userId = GetUserIdFromContext();
-
- var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
- if (user == null) return Unauthorized();
-
- 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);
- SetFeedId(feed, "collections");
-
-
- feed.Entries.AddRange(tags.Select(tag => new FeedEntry()
+ try
{
- Id = tag.Id.ToString(),
- Title = tag.Title,
- Summary = tag.Summary,
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
- $"{prefix}{apiKey}/collections/{tag.Id}"),
- CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
- $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}"),
- CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
- $"{baseUrl}api/image/collection-cover?collectionTagId={tag.Id}&apiKey={apiKey}")
- ]
- }));
+ var (baseUrl, prefix) = await GetPrefix();
- AddPagination(feed, tags, $"{prefix}{apiKey}/collections");
- return CreateXmlResult(SerializeXml(feed));
+ var feed = await _opdsService.GetCollections(new OpdsPaginatedCatalogueRequest()
+ {
+ BaseUrl = baseUrl,
+ Prefix = prefix,
+ UserId = GetUserIdFromContext(),
+ ApiKey = apiKey,
+ PageNumber = pageNumber
+ });
+
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
+ }
+ catch (OpdsException ex)
+ {
+ return BadRequest(ex.Message);
+ }
}
-
+ ///
+ /// Get Series for a given Collection - Supports Pagination
+ ///
+ ///
+ ///
+ ///
+ ///
[HttpGet("{apiKey}/collections/{collectionId}")]
[Produces("application/xml")]
- public async Task GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = 0)
+ public async Task GetCollection(int collectionId, string apiKey, [FromQuery] int pageNumber = 1)
{
- var userId = GetUserIdFromContext();
- var (baseUrl, prefix) = await GetPrefix();
- var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
- if (user == null) return Unauthorized();
-
- var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(collectionId);
- if (tag == null || (tag.AppUserId != user.Id && !tag.Promoted))
+ try
{
- return BadRequest("Collection does not exist or you don't have access");
+ var (baseUrl, prefix) = await GetPrefix();
+
+ var feed = await _opdsService.GetSeriesFromCollection(new OpdsItemsFromEntityIdRequest()
+ {
+ BaseUrl = baseUrl,
+ Prefix = prefix,
+ UserId = GetUserIdFromContext(),
+ ApiKey = apiKey,
+ PageNumber = pageNumber,
+ EntityId = collectionId
+ });
+
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
}
-
- var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForCollectionAsync(collectionId, userId, GetUserParams(pageNumber));
- var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id));
-
- var feed = CreateFeed(tag.Title + " Collection", $"{apiKey}/collections/{collectionId}", apiKey, prefix);
- SetFeedId(feed, $"collections-{collectionId}");
- AddPagination(feed, series, $"{prefix}{apiKey}/collections/{collectionId}");
-
- foreach (var seriesDto in series)
+ catch (OpdsException ex)
{
- feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl));
+ return BadRequest(ex.Message);
}
-
-
- return CreateXmlResult(SerializeXml(feed));
}
+ ///
+ /// Get a User's Reading Lists - Supports Pagination
+ ///
+ ///
+ ///
+ ///
[HttpGet("{apiKey}/reading-list")]
[Produces("application/xml")]
- public async Task GetReadingLists(string apiKey, [FromQuery] int pageNumber = 0)
+ public async Task GetReadingLists(string apiKey, [FromQuery] int pageNumber = 1)
{
- var userId = GetUserIdFromContext();
- var (baseUrl, prefix) = await GetPrefix();
-
- var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(userId,
- true, GetUserParams(pageNumber), false);
-
-
- var feed = CreateFeed("All Reading Lists", $"{apiKey}/reading-list", apiKey, prefix);
- SetFeedId(feed, "reading-list");
- AddPagination(feed, readingLists, $"{prefix}{apiKey}/reading-list/");
-
- foreach (var readingListDto in readingLists)
+ try
{
- feed.Entries.Add(new FeedEntry()
+ var (baseUrl, prefix) = await GetPrefix();
+
+ var feed = await _opdsService.GetReadingLists(new OpdsPaginatedCatalogueRequest()
{
- Id = readingListDto.Id.ToString(),
- Title = readingListDto.Title,
- Summary = readingListDto.Summary,
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
- $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"),
- CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
- $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}"),
- CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
- $"{baseUrl}api/image/readinglist-cover?readingListId={readingListDto.Id}&apiKey={apiKey}")
- ]
+ BaseUrl = baseUrl,
+ Prefix = prefix,
+ UserId = GetUserIdFromContext(),
+ ApiKey = apiKey,
+ PageNumber = pageNumber
});
+
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
+ }
+ catch (OpdsException ex)
+ {
+ return BadRequest(ex.Message);
}
-
-
- return CreateXmlResult(SerializeXml(feed));
}
-
+ ///
+ /// Returns individual items (chapters) from Reading List by ID - Supports Pagination
+ ///
+ ///
+ ///
+ ///
+ ///
[HttpGet("{apiKey}/reading-list/{readingListId}")]
[Produces("application/xml")]
- public async Task GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = 0)
+ public async Task GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = 1)
{
- var userId = GetUserIdFromContext();
-
- if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
+ try
{
- return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
- }
+ var (baseUrl, prefix) = await GetPrefix();
- var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
- if (user == null)
- {
- return Unauthorized();
- }
-
- var readingList = await _unitOfWork.ReadingListRepository.GetReadingListDtoByIdAsync(readingListId, user.Id);
- if (readingList == null)
- {
- return BadRequest(await _localizationService.Translate(userId, "reading-list-restricted"));
- }
-
- var (baseUrl, prefix) = await GetPrefix();
- 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, GetUserParams(pageNumber))).ToList();
- var totalItems = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).Count();
-
-
- // 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, userId);
-
- // If there is only one file underneath, add a direct acquisition link, otherwise add a subsection
- if (chapterDto is {Files.Count: 1})
+ var feed = await _opdsService.GetReadingListItems(new OpdsItemsFromEntityIdRequest()
{
- var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(item.SeriesId, userId);
- feed.Entries.Add(await CreateChapterWithFile(userId, item.SeriesId, item.VolumeId, item.ChapterId,
- chapterDto.Files.First(), series!, chapterDto, apiKey, prefix, baseUrl));
- }
- else
- {
- feed.Entries.Add(
- CreateChapter(apiKey, $"{item.Order} - {item.SeriesName}: {item.Title}",
- item.Summary ?? string.Empty, item.ChapterId, item.VolumeId, item.SeriesId, prefix, baseUrl));
- }
+ BaseUrl = baseUrl,
+ Prefix = prefix,
+ UserId = GetUserIdFromContext(),
+ ApiKey = apiKey,
+ PageNumber = pageNumber,
+ EntityId = readingListId
+ });
+
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
}
-
- AddPagination(feed, pageNumber, totalItems, UserParams.Default.PageSize, $"{prefix}{apiKey}/reading-list/{readingListId}/");
- 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})
+ catch (OpdsException ex)
{
- feed.Entries.Add(await CreateContinueReadingFromFile(userId, seriesId, chapterDto.VolumeId, chapterDto.Id,
- chapterDto.Files.First(), series!, chapterDto, apiKey, prefix, baseUrl));
+ return BadRequest(ex.Message);
}
}
- 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));
- }
- }
+ ///
+ /// Returns Series from the Library - Supports Pagination
+ ///
+ ///
+ ///
+ ///
+ ///
[HttpGet("{apiKey}/libraries/{libraryId}")]
[Produces("application/xml")]
- public async Task GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = 0)
+ public async Task GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = 1)
{
- var userId = GetUserIdFromContext();
- var (baseUrl, prefix) = await GetPrefix();
- var library =
- (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).SingleOrDefault(l =>
- l.Id == libraryId);
- if (library == null)
+ try
{
- return BadRequest(await _localizationService.Translate(userId, "no-library-access"));
+ var (baseUrl, prefix) = await GetPrefix();
+
+ var feed = await _opdsService.GetSeriesFromLibrary(new OpdsItemsFromEntityIdRequest()
+ {
+ BaseUrl = baseUrl,
+ Prefix = prefix,
+ UserId = GetUserIdFromContext(),
+ ApiKey = apiKey,
+ PageNumber = pageNumber,
+ EntityId = libraryId
+ });
+
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
}
-
- var filter = new FilterV2Dto
+ catch (OpdsException ex)
{
- Statements = [
- new ()
- {
- Comparison = FilterComparison.Equal,
- Field = FilterField.Libraries,
- Value = libraryId + string.Empty
- }
- ]
- };
-
- var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, GetUserParams(pageNumber), filter);
- var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id));
-
- var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey, prefix);
- SetFeedId(feed, $"library-{library.Name}");
- AddPagination(feed, series, $"{prefix}{apiKey}/libraries/{libraryId}");
-
- feed.Entries.AddRange(series.Select(seriesDto =>
- CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl)));
-
- return CreateXmlResult(SerializeXml(feed));
+ return BadRequest(ex.Message);
+ }
}
+ ///
+ /// Returns Recently Added (Dashboard Feed) - Supports Pagination
+ ///
+ ///
+ ///
+ ///
[HttpGet("{apiKey}/recently-added")]
[Produces("application/xml")]
public async Task GetRecentlyAdded(string apiKey, [FromQuery] int pageNumber = 1)
{
- var userId = GetUserIdFromContext();
- var (baseUrl, prefix) = await GetPrefix();
- var recentlyAdded = await _unitOfWork.SeriesRepository.GetRecentlyAddedV2(userId, GetUserParams(pageNumber), _filterV2Dto);
- var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(recentlyAdded.Select(s => s.Id));
-
- var feed = CreateFeed(await _localizationService.Translate(userId, "recently-added"), $"{apiKey}/recently-added", apiKey, prefix);
- SetFeedId(feed, "recently-added");
- AddPagination(feed, recentlyAdded, $"{prefix}{apiKey}/recently-added");
-
- foreach (var seriesDto in recentlyAdded)
+ try
{
- feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl));
- }
+ var (baseUrl, prefix) = await GetPrefix();
+ var feed = await _opdsService.GetRecentlyAdded(new OpdsPaginatedCatalogueRequest()
+ {
+ BaseUrl = baseUrl,
+ Prefix = prefix,
+ UserId = GetUserIdFromContext(),
+ ApiKey = apiKey,
+ PageNumber = pageNumber,
+ });
- return CreateXmlResult(SerializeXml(feed));
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
+ }
+ catch (OpdsException ex)
+ {
+ return BadRequest(ex.Message);
+ }
}
+ ///
+ /// Returns More In a Genre (Dashboard Feed) - Supports Pagination
+ ///
+ ///
+ ///
+ ///
+ ///
[HttpGet("{apiKey}/more-in-genre")]
[Produces("application/xml")]
public async Task GetMoreInGenre(string apiKey, [FromQuery] int genreId, [FromQuery] int pageNumber = 1)
{
- 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));
-
- var feed = CreateFeed(await _localizationService.Translate(userId, "more-in-genre", genre.Title), $"{apiKey}/more-in-genre", apiKey, prefix);
- SetFeedId(feed, "more-in-genre");
- AddPagination(feed, seriesDtos, $"{prefix}{apiKey}/more-in-genre");
-
- foreach (var seriesDto in seriesDtos)
+ try
{
- feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl));
- }
+ var (baseUrl, prefix) = await GetPrefix();
+ var feed = await _opdsService.GetMoreInGenre(new OpdsItemsFromEntityIdRequest()
+ {
+ BaseUrl = baseUrl,
+ Prefix = prefix,
+ UserId = GetUserIdFromContext(),
+ ApiKey = apiKey,
+ PageNumber = pageNumber,
+ EntityId = genreId
+ });
- return CreateXmlResult(SerializeXml(feed));
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
+ }
+ catch (OpdsException ex)
+ {
+ return BadRequest(ex.Message);
+ }
}
///
- /// Returns recently updated series. While pagination is avaible, total amount of pages is not due to implementation
- /// details
+ /// Get the Recently Updated Series (Dashboard) - Pagination available, total pages will not be filled due to underlying implementation
///
///
///
@@ -744,63 +450,54 @@ public class OpdsController : BaseApiController
[Produces("application/xml")]
public async Task GetRecentlyUpdated(string apiKey, [FromQuery] int pageNumber = 1)
{
- var userId = GetUserIdFromContext();
- if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
+ try
{
- return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
- }
-
- var userParams = new UserParams
- {
- PageNumber = pageNumber,
- PageSize = PageSize,
- };
- var seriesDtos = (await _unitOfWork.SeriesRepository.GetRecentlyUpdatedSeries(userId, userParams)).ToList();
- var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(seriesDtos.Select(s => s.SeriesId));
-
- var (baseUrl, prefix) = await GetPrefix();
- var feed = CreateFeed(await _localizationService.Translate(userId, "recently-updated"), $"{apiKey}/recently-updated", apiKey, prefix);
- SetFeedId(feed, "recently-updated");
-
- foreach (var groupedSeries in seriesDtos)
- {
- var seriesDto = new SeriesDto()
+ var (baseUrl, prefix) = await GetPrefix();
+ var feed = await _opdsService.GetRecentlyUpdated(new OpdsPaginatedCatalogueRequest()
{
- Name = $"{groupedSeries.SeriesName} ({groupedSeries.Count})",
- Id = groupedSeries.SeriesId,
- Format = groupedSeries.Format,
- LibraryId = groupedSeries.LibraryId,
- };
- var metadata = seriesMetadatas.First(s => s.SeriesId == seriesDto.Id);
- feed.Entries.Add(CreateSeries(seriesDto, metadata, apiKey, prefix, baseUrl));
- }
+ BaseUrl = baseUrl,
+ Prefix = prefix,
+ UserId = GetUserIdFromContext(),
+ ApiKey = apiKey,
+ PageNumber = pageNumber,
+ });
- return CreateXmlResult(SerializeXml(feed));
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
+ }
+ catch (OpdsException ex)
+ {
+ return BadRequest(ex.Message);
+ }
}
+ ///
+ /// Get the On Deck (Dashboard) - Supports Pagination
+ ///
+ ///
+ ///
+ ///
[HttpGet("{apiKey}/on-deck")]
[Produces("application/xml")]
public async Task GetOnDeck(string apiKey, [FromQuery] int pageNumber = 1)
{
- var userId = GetUserIdFromContext();
- var (baseUrl, prefix) = await GetPrefix();
-
- var userParams = GetUserParams(pageNumber);
- var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, 0, userParams, _filterDto);
- var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(pagedList.Select(s => s.Id));
-
- Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
-
- var feed = CreateFeed(await _localizationService.Translate(userId, "on-deck"), $"{apiKey}/on-deck", apiKey, prefix);
- SetFeedId(feed, "on-deck");
- AddPagination(feed, pagedList, $"{prefix}{apiKey}/on-deck");
-
- foreach (var seriesDto in pagedList)
+ try
{
- feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey, prefix, baseUrl));
- }
+ var (baseUrl, prefix) = await GetPrefix();
+ var feed = await _opdsService.GetOnDeck(new OpdsPaginatedCatalogueRequest()
+ {
+ BaseUrl = baseUrl,
+ Prefix = prefix,
+ UserId = GetUserIdFromContext(),
+ ApiKey = apiKey,
+ PageNumber = pageNumber,
+ });
- return CreateXmlResult(SerializeXml(feed));
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
+ }
+ catch (OpdsException ex)
+ {
+ return BadRequest(ex.Message);
+ }
}
///
@@ -813,71 +510,24 @@ public class OpdsController : BaseApiController
[Produces("application/xml")]
public async Task SearchSeries(string apiKey, [FromQuery] string query)
{
- var userId = GetUserIdFromContext();
- var (baseUrl, prefix) = await GetPrefix();
- var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
-
- if (string.IsNullOrEmpty(query))
+ try
{
- return BadRequest(await _localizationService.Translate(userId, "query-required"));
- }
- query = query.Replace(@"%", string.Empty);
-
- var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList();
- if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(userId, "libraries-restricted"));
-
- var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
-
- var searchResults = await _unitOfWork.SeriesRepository.SearchSeries(userId, isAdmin,
- libraries.Select(l => l.Id).ToArray(), query, includeChapterAndFiles: false);
-
- var feed = CreateFeed(query, $"{apiKey}/series?query=" + query, apiKey, prefix);
- SetFeedId(feed, "search-series");
- foreach (var seriesDto in searchResults.Series)
- {
- feed.Entries.Add(CreateSeries(seriesDto, apiKey, prefix, baseUrl));
- }
-
- foreach (var collection in searchResults.Collections)
- {
- feed.Entries.Add(new FeedEntry()
+ var (baseUrl, prefix) = await GetPrefix();
+ var feed = await _opdsService.Search(new OpdsSearchRequest()
{
- Id = collection.Id.ToString(),
- Title = collection.Title,
- Summary = collection.Summary,
- 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}")
- ]
+ BaseUrl = baseUrl,
+ Prefix = prefix,
+ UserId = GetUserIdFromContext(),
+ ApiKey = apiKey,
+ Query = query,
});
- }
- foreach (var readingListDto in searchResults.ReadingLists)
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
+ }
+ catch (OpdsException ex)
{
- feed.Entries.Add(new FeedEntry()
- {
- Id = readingListDto.Id.ToString(),
- Title = readingListDto.Title,
- Summary = readingListDto.Summary,
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"),
- ]
- });
+ return BadRequest(ex.Message);
}
-
- feed.Total = feed.Entries.Count;
- return CreateXmlResult(SerializeXml(feed));
- }
-
- private static void SetFeedId(Feed feed, string id)
- {
- feed.Id = id;
}
[HttpGet("{apiKey}/search")]
@@ -886,6 +536,7 @@ public class OpdsController : BaseApiController
{
var userId = GetUserIdFromContext();
var (_, prefix) = await GetPrefix();
+
var feed = new OpenSearchDescription()
{
ShortName = await _localizationService.Translate(userId, "search"),
@@ -903,164 +554,107 @@ public class OpdsController : BaseApiController
return CreateXmlResult(sm.ToString().Replace("utf-16", "utf-8"));
}
+ ///
+ /// Returns the items within a Series (Series Detail)
+ ///
+ ///
+ ///
+ ///
[HttpGet("{apiKey}/series/{seriesId}")]
[Produces("application/xml")]
- public async Task GetSeries(string apiKey, int seriesId)
+ public async Task GetSeriesDetail(string apiKey, int seriesId)
{
- var userId = GetUserIdFromContext();
- var (baseUrl, prefix) = await GetPrefix();
- var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
-
- var feed = CreateFeed(series!.Name + " - Storyline", $"{apiKey}/series/{series.Id}", apiKey, prefix);
- 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)
+ try
{
- var chapterDto = await _readerService.GetContinuePoint(seriesId, userId);
- await AddContinueReadingPoint(apiKey, seriesId, chapterDto, userId, feed, prefix, baseUrl);
- }
+ var (baseUrl, prefix) = await GetPrefix();
-
- 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.GetChapterDtosAsync(volume.Id, userId);
-
- foreach (var chapterDto in chaptersForVolume)
+ var feed = await _opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest()
{
- var chapterId = chapterDto.Id;
- if (!chapterDict.TryAdd(chapterId, 0)) continue;
+ BaseUrl = baseUrl,
+ Prefix = prefix,
+ UserId = GetUserIdFromContext(),
+ ApiKey = apiKey,
+ EntityId = seriesId
+ });
- 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;
-
- feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volume.Id, chapterId, _mapper.Map(mangaFile), series,
- chapterDto, apiKey, prefix, baseUrl));
- }
- }
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
}
-
- var chapters = seriesDetail.StorylineChapters;
- if (!seriesDetail.StorylineChapters.Any() && seriesDetail.Chapters.Any())
+ catch (OpdsException ex)
{
- chapters = seriesDetail.Chapters;
+ return BadRequest(ex.Message);
}
-
- foreach (var chapter in chapters.Where(c => !c.IsSpecial && !chapterDict.ContainsKey(c.Id)))
- {
- var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapter.Id);
- var chapterDto = _mapper.Map(chapter);
- foreach (var mangaFile in 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;
- feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, chapter.VolumeId, chapter.Id, _mapper.Map(mangaFile), series,
- chapterDto, apiKey, prefix, baseUrl));
- }
- }
-
- foreach (var special in seriesDetail.Specials)
- {
- var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(special.Id);
- var chapterDto = _mapper.Map(special);
- foreach (var mangaFile in 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;
-
- feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, special.VolumeId, special.Id, _mapper.Map(mangaFile), series,
- chapterDto, apiKey, prefix, baseUrl));
- }
- }
-
- return CreateXmlResult(SerializeXml(feed));
}
+ ///
+ /// Returns items for a given Volume
+ ///
+ ///
+ ///
+ ///
+ ///
[HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}")]
[Produces("application/xml")]
public async Task GetVolume(string apiKey, int seriesId, int volumeId)
{
- var userId = GetUserIdFromContext();
- var (baseUrl, prefix) = await GetPrefix();
-
- var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
- if (series == null)
+ try
{
- return NotFound();
- }
+ var (baseUrl, prefix) = await GetPrefix();
- var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters);
- if (volume == null)
- {
- return NotFound();
- }
-
- var feed = CreateFeed($"{series.Name} - Volume {volume!.Name}",
- $"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix);
- SetFeedId(feed, $"series-{series.Id}-volume-{volume.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 _readerService.GetContinuePoint(seriesId, userId);
- await AddContinueReadingPoint(apiKey, seriesId, chapterDto, userId, feed, prefix, baseUrl);
- }
-
- foreach (var chapterDto in chapterDtos)
- {
- foreach (var mangaFile in chapterDto.Files)
+ var feed = await _opdsService.GetItemsFromVolume(new OpdsItemsFromCompoundEntityIdsRequest()
{
- feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterDto.Id, mangaFile, series, chapterDto!, apiKey, prefix, baseUrl));
- }
- }
+ BaseUrl = baseUrl,
+ Prefix = prefix,
+ UserId = GetUserIdFromContext(),
+ ApiKey = apiKey,
+ SeriesId = seriesId,
+ VolumeId = volumeId
+ });
- return CreateXmlResult(SerializeXml(feed));
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
+ }
+ catch (OpdsException ex)
+ {
+ return BadRequest(ex.Message);
+ }
}
+ ///
+ /// Gets items for a given Chapter
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
[HttpGet("{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}")]
[Produces("application/xml")]
public async Task GetChapter(string apiKey, int seriesId, int volumeId, int chapterId)
{
- var userId = GetUserIdFromContext();
- 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, userId);
-
- if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist"));
-
- var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
-
- 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");
-
- foreach (var mangaFile in chapter.Files)
+ try
{
- feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterId, mangaFile, series, chapter, apiKey, prefix, baseUrl));
- }
+ var (baseUrl, prefix) = await GetPrefix();
- return CreateXmlResult(SerializeXml(feed));
+ var feed = await _opdsService.GetItemsFromChapter(new OpdsItemsFromCompoundEntityIdsRequest()
+ {
+ BaseUrl = baseUrl,
+ Prefix = prefix,
+ UserId = GetUserIdFromContext(),
+ ApiKey = apiKey,
+ SeriesId = seriesId,
+ VolumeId = volumeId,
+ ChapterId = chapterId
+ });
+
+ return CreateXmlResult(_opdsService.SerializeXml(feed));
+ }
+ catch (OpdsException ex)
+ {
+ return BadRequest(ex.Message);
+ }
}
///
- /// Downloads a file
+ /// Downloads a file (user must have download permission)
///
/// User's API Key
///
@@ -1075,7 +669,7 @@ public class OpdsController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (!await _accountService.HasDownloadPermission(user))
{
- return Forbid("User does not have download permissions");
+ return Forbid(await _localizationService.Translate(userId, "download-not-allowed"));
}
var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId);
@@ -1093,246 +687,6 @@ public class OpdsController : BaseApiController
};
}
- private static void AddPagination(Feed feed, PagedList list, string href)
- {
- var url = href;
- if (href.Contains('?'))
- {
- url += "&";
- }
- else
- {
- url += "?";
- }
-
- var pageNumber = Math.Max(list.CurrentPage, 1);
-
- if (pageNumber > 1)
- {
- feed.Links.Add(CreateLink(FeedLinkRelation.Prev, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber - 1)));
- }
-
- if (pageNumber + 1 <= list.TotalPages)
- {
- feed.Links.Add(CreateLink(FeedLinkRelation.Next, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber + 1)));
- }
-
- // Update self to point to current page
- var selfLink = feed.Links.SingleOrDefault(l => l.Rel == FeedLinkRelation.Self);
- if (selfLink != null)
- {
- selfLink.Href = url + "pageNumber=" + pageNumber;
- }
-
-
- feed.Total = list.TotalCount;
- feed.ItemsPerPage = list.PageSize;
- feed.StartIndex = (Math.Max(list.CurrentPage - 1, 0) * list.PageSize) + 1;
- }
-
- private static void AddPagination(Feed feed, int currentPage, int totalItems, int pageSize, string href)
- {
- var url = href;
- if (href.Contains('?'))
- {
- url += "&";
- }
- else
- {
- url += "?";
- }
-
- var pageNumber = Math.Max(currentPage, 1);
- var totalPages = totalItems / pageSize;
-
- if (pageNumber > 1)
- {
- feed.Links.Add(CreateLink(FeedLinkRelation.Prev, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber - 1)));
- }
-
- if (pageNumber + 1 <= totalPages)
- {
- feed.Links.Add(CreateLink(FeedLinkRelation.Next, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber + 1)));
- }
-
- // Update self to point to current page
- var selfLink = feed.Links.SingleOrDefault(l => l.Rel == FeedLinkRelation.Self);
- if (selfLink != null)
- {
- selfLink.Href = url + "pageNumber=" + pageNumber;
- }
-
-
- feed.Total = totalItems;
- feed.ItemsPerPage = pageSize;
- feed.StartIndex = (Math.Max(currentPage - 1, 0) * pageSize) + 1;
- }
-
-
- private static FeedEntry CreateSeries(SeriesDto seriesDto, SeriesMetadataDto metadata, string apiKey, string prefix, string baseUrl)
- {
- return new FeedEntry()
- {
- Id = seriesDto.Id.ToString(),
- Title = $"{seriesDto.Name}",
- Summary = $"Format: {seriesDto.Format}" + (string.IsNullOrWhiteSpace(metadata.Summary)
- ? string.Empty
- : $" Summary: {metadata.Summary}"),
- Authors = metadata.Writers.Select(CreateAuthor).ToList(),
- Categories = metadata.Genres.Select(g => new FeedCategory()
- {
- Label = g.Title,
- Term = string.Empty
- }).ToList(),
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
- $"{prefix}{apiKey}/series/{seriesDto.Id}"),
- CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
- $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}"),
- CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
- $"{baseUrl}api/image/series-cover?seriesId={seriesDto.Id}&apiKey={apiKey}")
- ]
- };
- }
-
- private static FeedEntry CreateSeries(SearchResultDto searchResultDto, string apiKey, string prefix, string baseUrl)
- {
- return new FeedEntry()
- {
- Id = searchResultDto.SeriesId.ToString(),
- Title = $"{searchResultDto.Name}",
- Summary = $"Format: {searchResultDto.Format}",
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
- $"{prefix}{apiKey}/series/{searchResultDto.SeriesId}"),
- CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
- $"{baseUrl}api/image/series-cover?seriesId={searchResultDto.SeriesId}&apiKey={apiKey}"),
- CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
- $"{baseUrl}api/image/series-cover?seriesId={searchResultDto.SeriesId}&apiKey={apiKey}")
- ]
- };
- }
-
- private static FeedAuthor CreateAuthor(PersonDto person)
- {
- return new FeedAuthor()
- {
- Name = person.Name,
- Uri = "http://opds-spec.org/author/" + person.Id
- };
- }
-
- private static FeedEntry CreateChapter(string apiKey, string title, string? summary, int chapterId, int volumeId, int seriesId, string prefix, string baseUrl)
- {
- return new FeedEntry()
- {
- Id = chapterId.ToString(),
- Title = title,
- Summary = summary ?? string.Empty,
-
- Links =
- [
- CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
- $"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}"),
- CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
- $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"),
- CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
- $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}")
- ]
- };
- }
-
- 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)
- {
- var fileSize =
- mangaFile.Bytes > 0 ? DirectoryService.GetHumanReadableBytes(mangaFile.Bytes) :
- DirectoryService.GetHumanReadableBytes(_directoryService.GetTotalSize(new List()
- {mangaFile.FilePath}));
- var fileType = _downloadService.GetContentTypeFromFile(mangaFile.FilePath);
- var filename = Uri.EscapeDataString(Path.GetFileName(mangaFile.FilePath));
- var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
- var volume = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId);
-
-
- var title = $"{series.Name}";
-
- if (volume!.Chapters.Count == 1 && !volume.IsSpecial())
- {
- var volumeLabel = await _localizationService.Translate(userId, "volume-num", string.Empty);
- SeriesService.RenameVolumeName(volume, libraryType, volumeLabel);
-
- if (!volume.IsLooseLeaf())
- {
- title += $" - {volume.Name}";
- }
- }
- else if (!volume.IsLooseLeaf() && !volume.IsSpecial())
- {
- title = $"{series.Name} - Volume {volume.Name} - {await _seriesService.FormatChapterTitle(userId, chapter, libraryType)}";
- }
- else
- {
- title = $"{series.Name} - {await _seriesService.FormatChapterTitle(userId, chapter, libraryType)}";
- }
-
- // Chunky requires a file at the end. Our API ignores this
- var accLink = CreateLink(FeedLinkRelation.Acquisition, fileType,
- $"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}",
- filename);
-
- accLink.TotalPages = chapter.Pages;
-
- var entry = new FeedEntry()
- {
- Id = mangaFile.Id.ToString(),
- Title = title,
- Extent = fileSize,
- Summary = $"File Type: {fileType.Split("/")[1]} - {fileSize}" + (string.IsNullOrWhiteSpace(chapter.Summary)
- ? string.Empty
- : $" Summary: {chapter.Summary}"),
- Format = mangaFile.Format.ToString(),
- Links =
- [
- CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
- $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"),
- CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
- $"{baseUrl}api/image/chapter-cover?chapterId={chapterId}&apiKey={apiKey}"),
- // We MUST include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly
- accLink
- ],
- Content = new FeedEntryContent()
- {
- Text = fileType,
- Type = "text"
- },
- Authors = chapter.Writers.Select(CreateAuthor).ToList()
- };
-
- var canPageStream = mangaFile.Extension != ".epub";
- if (canPageStream)
- {
- 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;
- }
///
/// This returns a streamed image following OPDS-PS v1.2
@@ -1367,9 +721,7 @@ public class OpdsController : BaseApiController
Response.AddCacheHeader(content);
// Save progress for the user (except Panels, they will use a direct connection)
- var userAgent = Request.Headers["User-Agent"].ToString();
-
-
+ var userAgent = Request.Headers.UserAgent.ToString();
if (!userAgent.StartsWith("Panels", StringComparison.InvariantCultureIgnoreCase) || !saveProgress)
{
@@ -1418,147 +770,4 @@ public class OpdsController : BaseApiController
return File(content, MimeTypeMap.GetMimeType(format));
}
-
- ///
- /// Gets the user from the API key
- ///
- ///
- private async Task GetUser(string apiKey)
- {
- try
- {
- return await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
- }
- catch
- {
- /* Do nothing */
- }
- throw new KavitaException(await _localizationService.Get("en", "user-doesnt-exist"));
- }
-
- private async Task CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFileDto mangaFile, string apiKey, string prefix)
- {
- var userId = await GetUser(apiKey);
- var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(chapterId, userId);
-
- // NOTE: Type could be wrong, there is nothing I can do in the spec
- var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg",
- $"{prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}");
- link.TotalPages = mangaFile.Pages;
- link.IsPageStream = true;
-
- if (progress != null)
- {
- link.LastRead = progress.PageNum;
- link.LastReadDate = progress.LastModifiedUtc.ToString("s"); // Adhere to ISO 8601
- }
-
- return link;
- }
-
- private static FeedLink CreateLink(string rel, string type, string href, string? title = null)
- {
- return new FeedLink()
- {
- Rel = rel,
- Href = href,
- Type = type,
- Title = string.IsNullOrEmpty(title) ? string.Empty : title
- };
- }
-
- private static Feed CreateFeed(string title, string href, string apiKey, string prefix)
- {
- var link = CreateLink(FeedLinkRelation.Self, string.IsNullOrEmpty(href) ?
- FeedLinkType.AtomNavigation :
- FeedLinkType.AtomAcquisition, prefix + href);
-
- return new Feed()
- {
- Title = title,
- Icon = $"{prefix}{apiKey}/favicon",
- Links =
- [
- link,
- CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}"),
- CreateLink(FeedLinkRelation.Search, FeedLinkType.AtomSearch, $"{prefix}{apiKey}/search")
- ],
- };
- }
-
- private string SerializeXml(Feed? feed)
- {
- if (feed == null) return string.Empty;
-
- // Remove invalid XML characters from the feed object
- SanitizeFeed(feed);
-
- using var sm = new StringWriter();
- _xmlSerializer.Serialize(sm, feed);
-
- var ret = sm.ToString().Replace("utf-16", "utf-8"); // Chunky cannot accept UTF-16 feeds
-
- return ret;
- }
-
- // Recursively sanitize all string properties in the object
- private static void SanitizeFeed(object? obj)
- {
- if (obj == null) return;
-
- var properties = obj.GetType().GetProperties();
- foreach (var property in properties)
- {
- // Skip properties that require an index (e.g., indexed collections)
- if (property.GetIndexParameters().Length > 0)
- continue;
-
- if (property.PropertyType == typeof(string) && property.CanWrite)
- {
- var value = (string?)property.GetValue(obj);
- if (!string.IsNullOrEmpty(value))
- {
- property.SetValue(obj, RemoveInvalidXmlChars(value));
- }
- }
- else if (property.PropertyType.IsClass) // Handle nested objects
- {
- var nestedObject = property.GetValue(obj);
- if (nestedObject != null)
- SanitizeFeed(nestedObject);
- }
- }
- }
-
- 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/DTOs/Annotations/FullAnnotationDto.cs b/API/DTOs/Annotations/FullAnnotationDto.cs
new file mode 100644
index 000000000..17dbe0579
--- /dev/null
+++ b/API/DTOs/Annotations/FullAnnotationDto.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Text.Json.Serialization;
+
+namespace API.DTOs.Annotations;
+
+public sealed record FullAnnotationDto
+{
+ public int Id { get; set; }
+ [JsonIgnore]
+ public int UserId { get; set; }
+ public string SelectedText { get; set; }
+ public string? Comment { get; set; }
+ public string? CommentHtml { get; set; }
+ public string? CommentPlainText { get; set; }
+ public string? Context { get; set; }
+ public string? ChapterTitle { get; set; }
+ public int PageNumber { get; set; }
+ public int SelectedSlotIndex { get; set; }
+ public bool ContainsSpoiler { get; set; }
+ public DateTime CreatedUtc { get; set; }
+ public DateTime LastModifiedUtc { get; set; }
+
+ public int LibraryId { get; set; }
+ public string LibraryName { get; set; }
+ public int SeriesId { get; set; }
+ public string SeriesName { get; set; }
+ public int VolumeId { get; set; }
+ public string VolumeName { get; set; }
+ public int ChapterId { get; set; }
+}
diff --git a/API/DTOs/Filtering/SortField.cs b/API/DTOs/Filtering/SortField.cs
index 7082ded69..6d8f3ef37 100644
--- a/API/DTOs/Filtering/SortField.cs
+++ b/API/DTOs/Filtering/SortField.cs
@@ -37,5 +37,13 @@ public enum SortField
///
/// Randomise the order
///
- Random = 9
+ Random = 9,
+}
+
+public enum AnnotationSortField
+{
+ Owner = 1,
+ Created = 2,
+ LastModified = 3,
+ Color = 4,
}
diff --git a/API/DTOs/Filtering/SortOptions.cs b/API/DTOs/Filtering/SortOptions.cs
index 18f2b17ea..864801e6b 100644
--- a/API/DTOs/Filtering/SortOptions.cs
+++ b/API/DTOs/Filtering/SortOptions.cs
@@ -17,3 +17,12 @@ public sealed record PersonSortOptions
public PersonSortField SortField { get; set; }
public bool IsAscending { get; set; } = true;
}
+
+///
+/// All Sorting Options for a query related to Annotation Entity
+///
+public sealed record AnnotationSortOptions
+{
+ public AnnotationSortField SortField { get; set; }
+ public bool IsAscending { get; set; } = true;
+}
diff --git a/API/DTOs/Filtering/v2/FilterField.cs b/API/DTOs/Filtering/v2/FilterField.cs
index 246a92a90..c00c15708 100644
--- a/API/DTOs/Filtering/v2/FilterField.cs
+++ b/API/DTOs/Filtering/v2/FilterField.cs
@@ -56,6 +56,10 @@ public enum FilterField
/// Last time User Read
///
ReadLast = 32,
+ ///
+ /// Total filesize accross all files for all chapters of the series
+ ///
+ FileSize = 33,
}
public enum PersonFilterField
@@ -65,3 +69,22 @@ public enum PersonFilterField
SeriesCount = 3,
ChapterCount = 4,
}
+
+public enum AnnotationFilterField
+{
+ Owner = 1,
+ Library = 2,
+ Spoiler = 3,
+ ///
+ /// When used, only returns your own annotations
+ ///
+ HighlightSlot = 4,
+ ///
+ /// This is the text selected in the book
+ ///
+ Selection = 5,
+ ///
+ /// This is the text the user wrote
+ ///
+ Comment = 6,
+}
diff --git a/API/DTOs/Filtering/v2/FilterStatementDto.cs b/API/DTOs/Filtering/v2/FilterStatementDto.cs
index 8c99bd24c..47d87e94c 100644
--- a/API/DTOs/Filtering/v2/FilterStatementDto.cs
+++ b/API/DTOs/Filtering/v2/FilterStatementDto.cs
@@ -1,5 +1,4 @@
-using API.DTOs.Metadata.Browse.Requests;
-
+
namespace API.DTOs.Filtering.v2;
public sealed record FilterStatementDto
@@ -15,3 +14,10 @@ public sealed record PersonFilterStatementDto
public PersonFilterField Field { get; set; }
public string Value { get; set; }
}
+
+public sealed record AnnotationFilterStatementDto
+{
+ public FilterComparison Comparison { get; set; }
+ public AnnotationFilterField Field { get; set; }
+ public string Value { get; set; }
+}
diff --git a/API/DTOs/Metadata/Browse/Requests/BrowseAnnotationFilterDto.cs b/API/DTOs/Metadata/Browse/Requests/BrowseAnnotationFilterDto.cs
new file mode 100644
index 000000000..4a0f08cda
--- /dev/null
+++ b/API/DTOs/Metadata/Browse/Requests/BrowseAnnotationFilterDto.cs
@@ -0,0 +1,26 @@
+#nullable enable
+using System.Collections.Generic;
+using API.DTOs.Filtering;
+using API.DTOs.Filtering.v2;
+
+namespace API.DTOs.Metadata.Browse.Requests;
+
+public class BrowseAnnotationFilterDto
+{
+ ///
+ /// Not used - For parity with Series Filter
+ ///
+ public int Id { get; set; }
+ ///
+ /// Not used - For parity with Series Filter
+ ///
+ public string? Name { get; set; }
+ public ICollection Statements { get; set; } = [];
+ public FilterCombination Combination { get; set; } = FilterCombination.And;
+ public AnnotationSortOptions? SortOptions { get; set; }
+
+ ///
+ /// Limit the number of rows returned. Defaults to not applying a limit (aka 0)
+ ///
+ public int LimitTo { get; set; } = 0;
+}
diff --git a/API/DTOs/OPDS/Feed.cs b/API/DTOs/OPDS/Internal/Feed.cs
similarity index 100%
rename from API/DTOs/OPDS/Feed.cs
rename to API/DTOs/OPDS/Internal/Feed.cs
diff --git a/API/DTOs/OPDS/FeedAuthor.cs b/API/DTOs/OPDS/Internal/FeedAuthor.cs
similarity index 100%
rename from API/DTOs/OPDS/FeedAuthor.cs
rename to API/DTOs/OPDS/Internal/FeedAuthor.cs
diff --git a/API/DTOs/OPDS/FeedCategory.cs b/API/DTOs/OPDS/Internal/FeedCategory.cs
similarity index 100%
rename from API/DTOs/OPDS/FeedCategory.cs
rename to API/DTOs/OPDS/Internal/FeedCategory.cs
diff --git a/API/DTOs/OPDS/FeedEntry.cs b/API/DTOs/OPDS/Internal/FeedEntry.cs
similarity index 100%
rename from API/DTOs/OPDS/FeedEntry.cs
rename to API/DTOs/OPDS/Internal/FeedEntry.cs
diff --git a/API/DTOs/OPDS/FeedEntryContent.cs b/API/DTOs/OPDS/Internal/FeedEntryContent.cs
similarity index 100%
rename from API/DTOs/OPDS/FeedEntryContent.cs
rename to API/DTOs/OPDS/Internal/FeedEntryContent.cs
diff --git a/API/DTOs/OPDS/FeedLink.cs b/API/DTOs/OPDS/Internal/FeedLink.cs
similarity index 100%
rename from API/DTOs/OPDS/FeedLink.cs
rename to API/DTOs/OPDS/Internal/FeedLink.cs
diff --git a/API/DTOs/OPDS/FeedLinkRelation.cs b/API/DTOs/OPDS/Internal/FeedLinkRelation.cs
similarity index 100%
rename from API/DTOs/OPDS/FeedLinkRelation.cs
rename to API/DTOs/OPDS/Internal/FeedLinkRelation.cs
diff --git a/API/DTOs/OPDS/FeedLinkType.cs b/API/DTOs/OPDS/Internal/FeedLinkType.cs
similarity index 100%
rename from API/DTOs/OPDS/FeedLinkType.cs
rename to API/DTOs/OPDS/Internal/FeedLinkType.cs
diff --git a/API/DTOs/OPDS/OpenSearchDescription.cs b/API/DTOs/OPDS/Internal/OpenSearchDescription.cs
similarity index 100%
rename from API/DTOs/OPDS/OpenSearchDescription.cs
rename to API/DTOs/OPDS/Internal/OpenSearchDescription.cs
diff --git a/API/DTOs/OPDS/SearchLink.cs b/API/DTOs/OPDS/Internal/SearchLink.cs
similarity index 100%
rename from API/DTOs/OPDS/SearchLink.cs
rename to API/DTOs/OPDS/Internal/SearchLink.cs
diff --git a/API/DTOs/OPDS/Requests/IOpdsPagination.cs b/API/DTOs/OPDS/Requests/IOpdsPagination.cs
new file mode 100644
index 000000000..7dba9cc8d
--- /dev/null
+++ b/API/DTOs/OPDS/Requests/IOpdsPagination.cs
@@ -0,0 +1,6 @@
+namespace API.DTOs.OPDS.Requests;
+
+public interface IOpdsPagination
+{
+ public int PageNumber { get; init; }
+}
diff --git a/API/DTOs/OPDS/Requests/IOpdsRequest.cs b/API/DTOs/OPDS/Requests/IOpdsRequest.cs
new file mode 100644
index 000000000..05e40b420
--- /dev/null
+++ b/API/DTOs/OPDS/Requests/IOpdsRequest.cs
@@ -0,0 +1,9 @@
+namespace API.DTOs.OPDS.Requests;
+
+public interface IOpdsRequest
+{
+ public string ApiKey { get; init; }
+ public string Prefix { get; init; }
+ public string BaseUrl { get; init; }
+ public int UserId { get; init; }
+}
diff --git a/API/DTOs/OPDS/Requests/OpdsCatalogeRequest.cs b/API/DTOs/OPDS/Requests/OpdsCatalogeRequest.cs
new file mode 100644
index 000000000..b81b66040
--- /dev/null
+++ b/API/DTOs/OPDS/Requests/OpdsCatalogeRequest.cs
@@ -0,0 +1,10 @@
+namespace API.DTOs.OPDS.Requests;
+
+
+public sealed record OpdsCatalogueRequest : IOpdsRequest
+{
+ public string ApiKey { get; init; }
+ public string Prefix { get; init; }
+ public string BaseUrl { get; init; }
+ public int UserId { get; init; }
+}
diff --git a/API/DTOs/OPDS/Requests/OpdsItemsFromCompoundEntityIdsRequest.cs b/API/DTOs/OPDS/Requests/OpdsItemsFromCompoundEntityIdsRequest.cs
new file mode 100644
index 000000000..2cfc48c74
--- /dev/null
+++ b/API/DTOs/OPDS/Requests/OpdsItemsFromCompoundEntityIdsRequest.cs
@@ -0,0 +1,18 @@
+namespace API.DTOs.OPDS.Requests;
+
+///
+/// A special case for dealing with lower level entities (volume/chapter) which need higher level entity ids
+///
+/// Not all variables will always be used. Implementation will use
+public sealed record OpdsItemsFromCompoundEntityIdsRequest : IOpdsRequest, IOpdsPagination
+{
+ public string ApiKey { get; init; }
+ public string Prefix { get; init; }
+ public string BaseUrl { get; init; }
+ public int UserId { get; init; }
+ public int PageNumber { get; init; }
+
+ public int SeriesId { get; init; }
+ public int VolumeId { get; init; }
+ public int ChapterId { get; init; }
+}
diff --git a/API/DTOs/OPDS/Requests/OpdsSearchRequest.cs b/API/DTOs/OPDS/Requests/OpdsSearchRequest.cs
new file mode 100644
index 000000000..24740b527
--- /dev/null
+++ b/API/DTOs/OPDS/Requests/OpdsSearchRequest.cs
@@ -0,0 +1,10 @@
+namespace API.DTOs.OPDS.Requests;
+
+public sealed record OpdsSearchRequest : IOpdsRequest
+{
+ public string ApiKey { get; init; }
+ public string Prefix { get; init; }
+ public string BaseUrl { get; init; }
+ public int UserId { get; init; }
+ public string Query { get; init; }
+}
diff --git a/API/DTOs/OPDS/Requests/OpdsSmartFilterCatalogueRequest.cs b/API/DTOs/OPDS/Requests/OpdsSmartFilterCatalogueRequest.cs
new file mode 100644
index 000000000..6572c57e9
--- /dev/null
+++ b/API/DTOs/OPDS/Requests/OpdsSmartFilterCatalogueRequest.cs
@@ -0,0 +1,14 @@
+namespace API.DTOs.OPDS.Requests;
+
+///
+/// A generic Catalogue request for a specific Entity
+///
+public sealed record OpdsPaginatedCatalogueRequest : IOpdsRequest, IOpdsPagination
+{
+ public string ApiKey { get; init; }
+ public string Prefix { get; init; }
+ public string BaseUrl { get; init; }
+ public int UserId { get; init; }
+
+ public int PageNumber { get; init; }
+}
diff --git a/API/DTOs/OPDS/Requests/OpdsSmartFilterRequest.cs b/API/DTOs/OPDS/Requests/OpdsSmartFilterRequest.cs
new file mode 100644
index 000000000..ba9420872
--- /dev/null
+++ b/API/DTOs/OPDS/Requests/OpdsSmartFilterRequest.cs
@@ -0,0 +1,12 @@
+namespace API.DTOs.OPDS.Requests;
+
+public sealed record OpdsItemsFromEntityIdRequest : IOpdsRequest, IOpdsPagination
+{
+ public string ApiKey { get; init; }
+ public string Prefix { get; init; }
+ public string BaseUrl { get; init; }
+ public int UserId { get; init; }
+
+ public int EntityId { get; init; }
+ public int PageNumber { get; init; } = 0;
+}
diff --git a/API/DTOs/Reader/AnnotationDto.cs b/API/DTOs/Reader/AnnotationDto.cs
index b73fa3baa..729f3ccda 100644
--- a/API/DTOs/Reader/AnnotationDto.cs
+++ b/API/DTOs/Reader/AnnotationDto.cs
@@ -28,6 +28,10 @@ public sealed record AnnotationDto
/// Rich text Comment
///
public string? Comment { get; set; }
+ ///
+ public string? CommentHtml { get; set; }
+ ///
+ public string? CommentPlainText { get; set; }
///
/// Title of the TOC Chapter within Epub (not Chapter Entity)
///
diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs
index 09e5d0e59..527ec6299 100644
--- a/API/DTOs/UserDto.cs
+++ b/API/DTOs/UserDto.cs
@@ -9,6 +9,7 @@ namespace API.DTOs;
public sealed record UserDto
{
+ public int Id { get; init; }
public string Username { get; init; } = null!;
public string Email { get; init; } = null!;
public IList Roles { get; set; } = [];
diff --git a/API/DTOs/UserReadingProfileDto.cs b/API/DTOs/UserReadingProfileDto.cs
index bae09ceb4..24dbf1c34 100644
--- a/API/DTOs/UserReadingProfileDto.cs
+++ b/API/DTOs/UserReadingProfileDto.cs
@@ -111,10 +111,6 @@ public sealed record UserReadingProfileDto
[Required]
public bool BookReaderImmersiveMode { get; set; } = false;
- ///
- [Required]
- public EpubPageCalculationMethod BookReaderEpubPageCalculationMethod { get; set; } = EpubPageCalculationMethod.Default;
-
#endregion
#region PdfReader
diff --git a/API/Data/Migrations/20250924142016_AddAnnotationsHtmlContent.Designer.cs b/API/Data/Migrations/20250924142016_AddAnnotationsHtmlContent.Designer.cs
new file mode 100644
index 000000000..d239e558d
--- /dev/null
+++ b/API/Data/Migrations/20250924142016_AddAnnotationsHtmlContent.Designer.cs
@@ -0,0 +1,3908 @@
+//
+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("20250924142016_AddAnnotationsHtmlContent")]
+ partial class AddAnnotationsHtmlContent
+ {
+ ///
+ 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("CommentHtml")
+ .HasColumnType("TEXT");
+
+ b.Property("CommentPlainText")
+ .HasColumnType("TEXT");
+
+ b.Property("ContainsSpoiler")
+ .HasColumnType("INTEGER");
+
+ b.Property("Context")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("EndingXPath")
+ .HasColumnType("TEXT");
+
+ b.Property("HighlightCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("INTEGER");
+
+ b.Property("PageNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("SelectedSlotIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property("SelectedText")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.Property("XPath")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AppUserId");
+
+ b.HasIndex("ChapterId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("AppUserAnnotation");
+ });
+
+ modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property("AppUserId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterId")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChapterTitle")
+ .HasColumnType("TEXT");
+
+ b.Property("Created")
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("FileName")
+ .HasColumnType("TEXT");
+
+ b.Property("ImageOffset")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property("LastModifiedUtc")
+ .HasColumnType("TEXT");
+
+ b.Property("Page")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("INTEGER");
+
+ b.Property("VolumeId")
+ .HasColumnType("INTEGER");
+
+ b.Property("XPath")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("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("BookReaderEpubPageCalculationMethod")
+ .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("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