OPDS Enhancements, Epub fixes, and a lot more (#4035)

Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: Fabian Pammer <fpammer@mantro.net>
Co-authored-by: Vinícius Licz <vinilicz@gmail.com>
This commit is contained in:
Joe Milazzo
2025-09-20 15:16:21 -05:00
committed by GitHub
parent 9891df898f
commit 26ff71f42b
339 changed files with 6923 additions and 1971 deletions
+8 -17
View File
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
@@ -17,7 +16,6 @@ using API.Errors;
using API.Extensions;
using API.Helpers.Builders;
using API.Services;
using API.Services.Plus;
using API.SignalR;
using AutoMapper;
using Hangfire;
@@ -29,7 +27,6 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SharpCompress;
namespace API.Controllers;
@@ -53,7 +50,6 @@ public class AccountController : BaseApiController
private readonly IEmailService _emailService;
private readonly IEventHub _eventHub;
private readonly ILocalizationService _localizationService;
private readonly IOidcService _oidcService;
/// <inheritdoc />
public AccountController(UserManager<AppUser> userManager,
@@ -62,8 +58,7 @@ public class AccountController : BaseApiController
ILogger<AccountController> logger,
IMapper mapper, IAccountService accountService,
IEmailService emailService, IEventHub eventHub,
ILocalizationService localizationService,
IOidcService oidcService)
ILocalizationService localizationService)
{
_userManager = userManager;
_signInManager = signInManager;
@@ -75,7 +70,6 @@ public class AccountController : BaseApiController
_emailService = emailService;
_eventHub = eventHub;
_localizationService = localizationService;
_oidcService = oidcService;
}
/// <summary>
@@ -197,11 +191,7 @@ public class AccountController : BaseApiController
var result = await _userManager.CreateAsync(user, registerDto.Password);
if (!result.Succeeded) return BadRequest(result.Errors);
// Assign default streams
_accountService.AddDefaultStreamsToUser(user);
// Assign default reading profile
await _accountService.AddDefaultReadingProfileToUser(user);
await _accountService.SeedUser(user);
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
if (string.IsNullOrEmpty(token)) return BadRequest(await _localizationService.Get("en", "confirm-token-gen"));
@@ -534,6 +524,11 @@ public class AccountController : BaseApiController
return Ok();
}
/// <summary>
/// Change the Age Rating restriction for the user
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("update/age-restriction")]
public async Task<ActionResult> UpdateAgeRestriction(UpdateAgeRestrictionDto dto)
{
@@ -745,11 +740,7 @@ public class AccountController : BaseApiController
var result = await _userManager.CreateAsync(user, AccountService.DefaultPassword);
if (!result.Succeeded) return BadRequest(result.Errors);
// Assign default streams
_accountService.AddDefaultStreamsToUser(user);
// Assign default reading profile
await _accountService.AddDefaultReadingProfileToUser(user);
await _accountService.SeedUser(user);
// Assign Roles
var roles = dto.Roles;
+11
View File
@@ -45,6 +45,17 @@ public class AnnotationController : BaseApiController
return Ok(await _unitOfWork.UserRepository.GetAnnotations(User.GetUserId(), chapterId));
}
/// <summary>
/// Returns all annotations by Series
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("all-for-series")]
public async Task<ActionResult<AnnotationDto>> GetAnnotationsBySeries(int seriesId)
{
return Ok(await _unitOfWork.UserRepository.GetAnnotationDtosBySeries(User.GetUserId(), seriesId));
}
/// <summary>
/// Returns the Annotation by Id. User must have access to annotation.
/// </summary>
+6 -9
View File
@@ -111,19 +111,16 @@ public class BookController : BaseApiController
public async Task<ActionResult> GetBookPageResources(int chapterId, [FromQuery] string file)
{
if (chapterId <= 0) return BadRequest(await _localizationService.Get("en", "chapter-doesnt-exist"));
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
var chapter = await _cacheService.Ensure(chapterId);
if (chapter == null) return BadRequest(await _localizationService.Get("en", "chapter-doesnt-exist"));
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.LenientBookReaderOptions);
var key = BookService.CoalesceKeyForAnyFile(book, file);
var cachedFilePath = Path.Join(_cacheService.GetCachePath(chapterId), Path.GetFileName(chapter.Files.ElementAt(0).FilePath));
var result = await _bookService.GetResourceAsync(cachedFilePath, file);
if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) return BadRequest(await _localizationService.Get("en", "file-missing"));
if (!result.IsSuccess) return BadRequest(await _localizationService.Translate(User.GetUserId(), result.ErrorMessage));
var bookFile = book.Content.AllFiles.GetLocalFileRefByKey(key);
var content = await bookFile.ReadContentAsBytesAsync();
var contentType = BookService.GetContentType(bookFile.ContentType);
return File(content, contentType, $"{chapterId}-{file}");
return File(result.Content, result.ContentType, $"{chapterId}-{file}");
}
/// <summary>
+1 -3
View File
@@ -51,9 +51,7 @@ public class ChapterController : BaseApiController
[HttpGet]
public async Task<ActionResult<ChapterDto>> GetChapter(int chapterId)
{
var chapter =
await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId,
ChapterIncludes.People | ChapterIncludes.Files);
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, User.GetUserId());
return Ok(chapter);
}
+1 -1
View File
@@ -50,7 +50,7 @@ public class ColorScapeController : BaseApiController
[HttpGet("chapter")]
public async Task<ActionResult<ColorScapeDto>> GetColorScapeForChapter(int id)
{
var entity = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(id);
var entity = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(id, User.GetUserId());
return GetColorSpaceDto(entity);
}
+6 -1
View File
@@ -20,8 +20,13 @@ public class FallbackController : Controller
_taskScheduler = taskScheduler;
}
public PhysicalFileResult Index()
public IActionResult Index()
{
if (HttpContext.Request.Path.StartsWithSegments("/api"))
{
return NotFound();
}
return PhysicalFile(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "index.html"), "text/HTML");
}
}
+203 -140
View File
@@ -18,6 +18,7 @@ using API.DTOs.Filtering.v2;
using API.DTOs.OPDS;
using API.DTOs.Person;
using API.DTOs.Progress;
using API.DTOs.ReadingLists;
using API.DTOs.Search;
using API.Entities;
using API.Entities.Enums;
@@ -105,33 +106,32 @@ public class OpdsController : BaseApiController
private readonly XmlSerializer _xmlSerializer;
private readonly XmlSerializer _xmlOpenSearchSerializer;
private readonly FilterDto _filterDto = new FilterDto()
private readonly FilterDto _filterDto = new()
{
Formats = new List<MangaFormat>(),
Character = new List<int>(),
Colorist = new List<int>(),
Editor = new List<int>(),
Genres = new List<int>(),
Inker = new List<int>(),
Languages = new List<string>(),
Letterer = new List<int>(),
Penciller = new List<int>(),
Libraries = new List<int>(),
Publisher = new List<int>(),
Formats = [],
Character = [],
Colorist = [],
Editor = [],
Genres = [],
Inker = [],
Languages = [],
Letterer = [],
Penciller = [],
Libraries = [],
Publisher = [],
Rating = 0,
Tags = new List<int>(),
Translators = new List<int>(),
Writers = new List<int>(),
AgeRating = new List<AgeRating>(),
CollectionTags = new List<int>(),
CoverArtist = new List<int>(),
Tags = [],
Translators = [],
Writers = [],
AgeRating = [],
CollectionTags = [],
CoverArtist = [],
ReadStatus = new ReadStatus(),
SortOptions = null,
PublicationStatus = new List<PublicationStatus>()
PublicationStatus = []
};
private readonly FilterV2Dto _filterV2Dto = new FilterV2Dto();
private readonly ChapterSortComparerDefaultLast _chapterSortComparerDefaultLast = ChapterSortComparerDefaultLast.Default;
private readonly FilterV2Dto _filterV2Dto = new();
private const int PageSize = 20;
public const string UserId = nameof(UserId);
@@ -187,10 +187,10 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-on-deck")
},
Links = new List<FeedLink>()
{
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/on-deck"),
}
]
});
break;
case DashboardStreamType.NewlyAdded:
@@ -202,10 +202,10 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-recently-added")
},
Links = new List<FeedLink>()
{
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-added"),
}
]
});
break;
case DashboardStreamType.RecentlyUpdated:
@@ -217,10 +217,10 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-recently-updated")
},
Links = new List<FeedLink>()
{
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/recently-updated"),
}
]
});
break;
case DashboardStreamType.MoreInGenre:
@@ -235,10 +235,10 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-more-in-genre", randomGenre.Title)
},
Links = new List<FeedLink>()
{
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/more-in-genre?genreId={randomGenre.Id}"),
}
]
});
break;
case DashboardStreamType.SmartFilter:
@@ -269,10 +269,10 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-reading-lists")
},
Links = new List<FeedLink>()
{
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list"),
}
]
});
feed.Entries.Add(new FeedEntry()
{
@@ -282,10 +282,10 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-want-to-read")
},
Links = new List<FeedLink>()
{
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/want-to-read"),
}
]
});
feed.Entries.Add(new FeedEntry()
{
@@ -295,10 +295,10 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-libraries")
},
Links = new List<FeedLink>()
{
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/libraries"),
}
]
});
feed.Entries.Add(new FeedEntry()
{
@@ -308,10 +308,10 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-collections")
},
Links = new List<FeedLink>()
{
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/collections"),
}
]
});
if ((_unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId)).Any())
@@ -324,30 +324,13 @@ public class OpdsController : BaseApiController
{
Text = await _localizationService.Translate(userId, "browse-smart-filters")
},
Links = new List<FeedLink>()
{
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/smart-filters"),
}
]
});
}
// if ((await _unitOfWork.AppUserExternalSourceRepository.GetExternalSources(userId)).Any())
// {
// feed.Entries.Add(new FeedEntry()
// {
// Id = "allExternalSources",
// Title = await _localizationService.Translate(userId, "external-sources"),
// Content = new FeedEntryContent()
// {
// Text = await _localizationService.Translate(userId, "browse-external-sources")
// },
// Links = new List<FeedLink>()
// {
// CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/external-sources"),
// }
// });
// }
return CreateXmlResult(SerializeXml(feed));
}
@@ -396,12 +379,12 @@ public class OpdsController : BaseApiController
[HttpGet("{apiKey}/smart-filters")]
[Produces("application/xml")]
public async Task<IActionResult> GetSmartFilters(string apiKey)
public async Task<IActionResult> GetSmartFilters(string apiKey, [FromQuery] int pageNumber = 0)
{
var userId = GetUserIdFromContext();
var (_, prefix) = await GetPrefix();
var filters = _unitOfWork.AppUserSmartFilterRepository.GetAllDtosByUserId(userId);
var filters = await _unitOfWork.AppUserSmartFilterRepository.GetPagedDtosByUserIdAsync(userId, GetUserParams(pageNumber));
var feed = CreateFeed(await _localizationService.Translate(userId, "smartFilters"), $"{apiKey}/smart-filters", apiKey, prefix);
SetFeedId(feed, "smartFilters");
@@ -419,40 +402,10 @@ public class OpdsController : BaseApiController
});
}
AddPagination(feed, filters, $"{prefix}{apiKey}/smart-filters");
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/external-sources")]
[Produces("application/xml")]
public async Task<IActionResult> GetExternalSources(string apiKey)
{
// NOTE: This doesn't seem possible in OPDS v2.1 due to the resulting stream using relative links and most apps resolve against source url. Even using full paths doesn't work
var userId = GetUserIdFromContext();
var (_, prefix) = await GetPrefix();
var externalSources = await _unitOfWork.AppUserExternalSourceRepository.GetExternalSources(userId);
var feed = CreateFeed(await _localizationService.Translate(userId, "external-sources"), $"{apiKey}/external-sources", apiKey, prefix);
SetFeedId(feed, "externalSources");
foreach (var externalSource in externalSources)
{
var opdsUrl = $"{externalSource.Host}api/opds/{externalSource.ApiKey}";
feed.Entries.Add(new FeedEntry()
{
Id = externalSource.Id.ToString(),
Title = externalSource.Name,
Summary = externalSource.Host,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.Start, FeedLinkType.AtomNavigation, opdsUrl),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"{opdsUrl}/favicon")
}
});
}
return CreateXmlResult(SerializeXml(feed));
}
[HttpGet("{apiKey}/libraries")]
[Produces("application/xml")]
public async Task<IActionResult> GetLibraries(string apiKey)
@@ -463,7 +416,7 @@ public class OpdsController : BaseApiController
SetFeedId(feed, "libraries");
// Ensure libraries follow SideNav order
var userSideNavStreams = await _unitOfWork.UserRepository.GetSideNavStreams(userId, false);
var userSideNavStreams = await _unitOfWork.UserRepository.GetSideNavStreams(userId);
foreach (var library in userSideNavStreams.Where(s => s.StreamType == SideNavStreamType.Library).Select(sideNavStream => sideNavStream.Library))
{
feed.Entries.Add(new FeedEntry()
@@ -506,14 +459,14 @@ public class OpdsController : BaseApiController
[HttpGet("{apiKey}/collections")]
[Produces("application/xml")]
public async Task<IActionResult> GetCollections(string apiKey)
public async Task<IActionResult> GetCollections(string apiKey, [FromQuery] int pageNumber = 0)
{
var userId = GetUserIdFromContext();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null) return Unauthorized();
var tags = await _unitOfWork.CollectionTagRepository.GetCollectionDtosAsync(user.Id, true);
var tags = await _unitOfWork.CollectionTagRepository.GetCollectionDtosPagedAsync(user.Id, GetUserParams(pageNumber), true);
var (baseUrl, prefix) = await GetPrefix();
var feed = CreateFeed(await _localizationService.Translate(userId, "collections"), $"{apiKey}/collections", apiKey, prefix);
@@ -536,6 +489,7 @@ public class OpdsController : BaseApiController
]
}));
AddPagination(feed, tags, $"{prefix}{apiKey}/collections");
return CreateXmlResult(SerializeXml(feed));
}
@@ -609,14 +563,6 @@ public class OpdsController : BaseApiController
return CreateXmlResult(SerializeXml(feed));
}
private static UserParams GetUserParams(int pageNumber)
{
return new UserParams()
{
PageNumber = pageNumber,
PageSize = PageSize
};
}
[HttpGet("{apiKey}/reading-list/{readingListId}")]
[Produces("application/xml")]
@@ -645,13 +591,22 @@ public class OpdsController : BaseApiController
var feed = CreateFeed(readingList.Title + " " + await _localizationService.Translate(userId, "reading-list"), $"{apiKey}/reading-list/{readingListId}", apiKey, prefix);
SetFeedId(feed, $"reading-list-{readingListId}");
var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId);
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId, GetUserParams(pageNumber))).ToList();
// Check if there is reading progress or not, if so, inject a "continue-reading" item
var firstReadReadingListItem = items.FirstOrDefault(i => i.PagesRead > 0);
if (firstReadReadingListItem != null)
{
await AddContinueReadingPoint(apiKey, firstReadReadingListItem, userId, feed, prefix, baseUrl);
}
foreach (var item in items)
{
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(item.ChapterId);
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(item.ChapterId, userId);
// If there is only one file underneath, add a direct acquisition link, otherwise add a subsection
if (chapterDto != null && chapterDto.Files.Count == 1)
if (chapterDto is {Files.Count: 1})
{
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(item.SeriesId, userId);
feed.Entries.Add(await CreateChapterWithFile(userId, item.SeriesId, item.VolumeId, item.ChapterId,
@@ -668,6 +623,29 @@ public class OpdsController : BaseApiController
return CreateXmlResult(SerializeXml(feed));
}
private async Task AddContinueReadingPoint(string apiKey, int seriesId, ChapterDto chapterDto, int userId,
Feed feed, string prefix, string baseUrl)
{
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
if (chapterDto is {Files.Count: 1})
{
feed.Entries.Add(await CreateContinueReadingFromFile(userId, seriesId, chapterDto.VolumeId, chapterDto.Id,
chapterDto.Files.First(), series!, chapterDto, apiKey, prefix, baseUrl));
}
}
private async Task AddContinueReadingPoint(string apiKey, ReadingListItemDto firstReadReadingListItem, int userId,
Feed feed, string prefix, string baseUrl)
{
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(firstReadReadingListItem.ChapterId, userId);
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(firstReadReadingListItem.SeriesId, userId);
if (chapterDto is {Files.Count: 1})
{
feed.Entries.Add(await CreateContinueReadingFromFile(userId, firstReadReadingListItem.SeriesId, firstReadReadingListItem.VolumeId, firstReadReadingListItem.ChapterId,
chapterDto.Files.First(), series!, chapterDto, apiKey, prefix, baseUrl));
}
}
[HttpGet("{apiKey}/libraries/{libraryId}")]
[Produces("application/xml")]
public async Task<IActionResult> GetSeriesForLibrary(int libraryId, string apiKey, [FromQuery] int pageNumber = 0)
@@ -684,14 +662,14 @@ public class OpdsController : BaseApiController
var filter = new FilterV2Dto
{
Statements = new List<FilterStatementDto>() {
Statements = [
new ()
{
Comparison = FilterComparison.Equal,
Field = FilterField.Libraries,
Value = libraryId + string.Empty
}
}
]
};
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdV2Async(userId, GetUserParams(pageNumber), filter);
@@ -735,6 +713,7 @@ public class OpdsController : BaseApiController
var userId = GetUserIdFromContext();
var (baseUrl, prefix) = await GetPrefix();
var genre = await _unitOfWork.GenreRepository.GetGenreById(genreId);
if (genre == null) return BadRequest(await _localizationService.Translate(userId, "genre-doesnt-exist"));
var seriesDtos = await _unitOfWork.SeriesRepository.GetMoreIn(userId, 0, genreId, GetUserParams(pageNumber));
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(seriesDtos.Select(s => s.Id));
@@ -750,6 +729,13 @@ public class OpdsController : BaseApiController
return CreateXmlResult(SerializeXml(feed));
}
/// <summary>
/// Returns recently updated series. While pagination is avaible, total amount of pages is not due to implementation
/// details
/// </summary>
/// <param name="apiKey"></param>
/// <param name="pageNumber"></param>
/// <returns></returns>
[HttpGet("{apiKey}/recently-updated")]
[Produces("application/xml")]
public async Task<IActionResult> GetRecentlyUpdated(string apiKey, [FromQuery] int pageNumber = 1)
@@ -832,7 +818,7 @@ public class OpdsController : BaseApiController
return BadRequest(await _localizationService.Translate(userId, "query-required"));
}
query = query.Replace(@"%", string.Empty);
// Get libraries user has access to
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList();
if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(userId, "libraries-restricted"));
@@ -855,15 +841,15 @@ public class OpdsController : BaseApiController
Id = collection.Id.ToString(),
Title = collection.Title,
Summary = collection.Summary,
Links = new List<FeedLink>()
{
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
$"{prefix}{apiKey}/collections/{collection.Id}"),
CreateLink(FeedLinkRelation.Image, FeedLinkType.Image,
$"{baseUrl}api/image/collection-cover?collectionId={collection.Id}&apiKey={apiKey}"),
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image,
$"{baseUrl}api/image/collection-cover?collectionId={collection.Id}&apiKey={apiKey}")
}
]
});
}
@@ -874,14 +860,14 @@ public class OpdsController : BaseApiController
Id = readingListDto.Id.ToString(),
Title = readingListDto.Title,
Summary = readingListDto.Summary,
Links = new List<FeedLink>()
{
Links =
[
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, $"{prefix}{apiKey}/reading-list/{readingListDto.Id}"),
}
]
});
}
// TODO: Search should allow Chapters/Files and more
feed.Total = feed.Entries.Count;
return CreateXmlResult(SerializeXml(feed));
}
@@ -926,20 +912,30 @@ public class OpdsController : BaseApiController
SetFeedId(feed, $"series-{series.Id}");
feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"{baseUrl}api/image/series-cover?seriesId={seriesId}&apiKey={apiKey}"));
// Check if there is reading progress or not, if so, inject a "continue-reading" item
var anyUserProgress = await _unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId);
if (anyUserProgress)
{
var chapterDto = await _readerService.GetContinuePoint(seriesId, userId);
await AddContinueReadingPoint(apiKey, seriesId, chapterDto, userId, feed, prefix, baseUrl);
}
var chapterDict = new Dictionary<int, short>();
var fileDict = new Dictionary<int, short>();
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
foreach (var volume in seriesDetail.Volumes)
{
var chaptersForVolume = await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id, ChapterIncludes.Files | ChapterIncludes.People);
var chaptersForVolume = await _unitOfWork.ChapterRepository.GetChapterDtosAsync(volume.Id, userId);
foreach (var chapter in chaptersForVolume)
foreach (var chapterDto in chaptersForVolume)
{
var chapterId = chapter.Id;
var chapterId = chapterDto.Id;
if (!chapterDict.TryAdd(chapterId, 0)) continue;
var chapterDto = _mapper.Map<ChapterDto>(chapter);
foreach (var mangaFile in chapter.Files)
foreach (var mangaFile in chapterDto.Files)
{
// If a chapter has multiple files that are within one chapter, this dict prevents duplicate key exception
if (!fileDict.TryAdd(mangaFile.Id, 0)) continue;
@@ -999,25 +995,31 @@ public class OpdsController : BaseApiController
return NotFound();
}
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters);
if (volume == null)
{
return NotFound();
}
var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s ",
var feed = CreateFeed($"{series.Name} - Volume {volume!.Name}",
$"{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix);
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{_seriesService.FormatChapterName(userId, libraryType)}s");
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}");
foreach (var chapterId in volume.Chapters.Select(c => c.Id))
var chapterDtos = await _unitOfWork.ChapterRepository.GetChapterDtoByIdsAsync(volume.Chapters.Select(c => c.Id), userId);
// Check if there is reading progress or not, if so, inject a "continue-reading" item
var firstChapterWithProgress = chapterDtos.FirstOrDefault(c => c.PagesRead > 0);
if (firstChapterWithProgress != null)
{
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, ChapterIncludes.Files | ChapterIncludes.People);
if (chapterDto == null) continue;
var chapterDto = await _readerService.GetContinuePoint(seriesId, userId);
await AddContinueReadingPoint(apiKey, seriesId, chapterDto, userId, feed, prefix, baseUrl);
}
foreach (var chapterDto in chapterDtos)
{
foreach (var mangaFile in chapterDto.Files)
{
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterId, mangaFile, series, chapterDto!, apiKey, prefix, baseUrl));
feed.Entries.Add(await CreateChapterWithFile(userId, seriesId, volumeId, chapterDto.Id, mangaFile, series, chapterDto!, apiKey, prefix, baseUrl));
}
}
@@ -1032,14 +1034,17 @@ public class OpdsController : BaseApiController
var (baseUrl, prefix) = await GetPrefix();
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
if (series == null) return BadRequest(await _localizationService.Translate(userId, "series-doesnt-exist"));
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, ChapterIncludes.Files | ChapterIncludes.People);
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, userId);
if (chapter == null) return BadRequest(await _localizationService.Translate(userId, "chapter-doesnt-exist"));
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s",
var chapterName = await _seriesService.FormatChapterName(userId, libraryType);
var feed = CreateFeed( $"{series.Name} - Volume {volume!.Name} - {chapterName} {chapterId}",
$"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey, prefix);
SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{_seriesService.FormatChapterName(userId, libraryType)}-{chapterId}-files");
@@ -1197,6 +1202,17 @@ public class OpdsController : BaseApiController
};
}
private async Task<FeedEntry> 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<FeedEntry> CreateChapterWithFile(int userId, int seriesId, int volumeId, int chapterId,
MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, string apiKey, string prefix, string baseUrl)
{
@@ -1216,6 +1232,7 @@ public class OpdsController : BaseApiController
{
var volumeLabel = await _localizationService.Translate(userId, "volume-num", string.Empty);
SeriesService.RenameVolumeName(volume, libraryType, volumeLabel);
if (!volume.IsLooseLeaf())
{
title += $" - {volume.Name}";
@@ -1231,10 +1248,10 @@ public class OpdsController : BaseApiController
}
// Chunky requires a file at the end. Our API ignores this
var accLink =
CreateLink(FeedLinkRelation.Acquisition, fileType,
var accLink = CreateLink(FeedLinkRelation.Acquisition, fileType,
$"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}/download/{filename}",
filename);
accLink.TotalPages = chapter.Pages;
var entry = new FeedEntry()
@@ -1269,6 +1286,9 @@ public class OpdsController : BaseApiController
entry.Links.Add(await CreatePageStreamLink(series.LibraryId, seriesId, volumeId, chapterId, mangaFile, apiKey, prefix));
}
// Patch in reading status on the item (as OPDS is seriously lacking)
entry.Title = $"{GetReadingProgressIcon(chapter.PagesRead, chapter.Pages)} {entry.Title}";
return entry;
}
@@ -1306,12 +1326,27 @@ public class OpdsController : BaseApiController
// Save progress for the user (except Panels, they will use a direct connection)
var userAgent = Request.Headers["User-Agent"].ToString();
if (!userAgent.StartsWith("Panels", StringComparison.InvariantCultureIgnoreCase) || !saveProgress)
{
// Kavita expects 0-N for progress, KOReader doesn't respect the OPDS-PS spec and does some wierd stuff
// https://github.com/Kareadita/Kavita/pull/4014#issuecomment-3313677492
var koreaderOffset = 0;
if (userAgent.StartsWith("Koreader", StringComparison.InvariantCultureIgnoreCase))
{
var totalPages = await _unitOfWork.ChapterRepository.GetChapterTotalPagesAsync(chapterId);
if (totalPages - pageNumber < 2)
{
koreaderOffset = 1;
}
}
await _readerService.SaveReadingProgress(new ProgressDto()
{
ChapterId = chapterId,
PageNum = pageNumber,
PageNum = pageNumber + koreaderOffset,
SeriesId = seriesId,
VolumeId = volumeId,
LibraryId =libraryId
@@ -1322,7 +1357,7 @@ public class OpdsController : BaseApiController
}
catch (Exception)
{
_cacheService.CleanupChapters(new []{ chapterId });
_cacheService.CleanupChapters([chapterId]);
throw;
}
}
@@ -1334,6 +1369,7 @@ public class OpdsController : BaseApiController
var userId = GetUserIdFromContext();
var files = _directoryService.GetFilesWithExtension(Path.Join(Directory.GetCurrentDirectory(), ".."), @"\.ico");
if (files.Length == 0) return BadRequest(await _localizationService.Translate(userId, "favicon-doesnt-exist"));
var path = files[0];
var content = await _directoryService.ReadFileAsync(path);
var format = Path.GetExtension(path);
@@ -1452,8 +1488,35 @@ public class OpdsController : BaseApiController
}
}
private static UserParams GetUserParams(int pageNumber)
{
return new UserParams()
{
PageNumber = pageNumber,
PageSize = PageSize
};
}
private static string RemoveInvalidXmlChars(string input)
{
return new string(input.Where(XmlConvert.IsXmlChar).ToArray());
}
private static string GetReadingProgressIcon(int pagesRead, int totalPages)
{
if (pagesRead == 0) return "⭘";
var percentageRead = (double)pagesRead / totalPages;
return percentageRead switch
{
// 100%
>= 1.0 => "⬤",
// > 50% and < 100%
> 0.5 => "◕",
// > 25% and <= 50%
> 0.25 => "◑",
_ => "◔"
};
}
}
+8 -6
View File
@@ -516,7 +516,7 @@ public class ReaderController : BaseApiController
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
if (user == null) return Unauthorized();
user.Progresses ??= new List<AppUserProgress>();
user.Progresses ??= [];
var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(dto.SeriesIds.ToArray(), true);
foreach (var volume in volumes)
@@ -566,9 +566,11 @@ public class ReaderController : BaseApiController
public async Task<ActionResult> SaveProgress(ProgressDto progressDto)
{
var userId = User.GetUserId();
if (!await _readerService.SaveReadingProgress(progressDto, userId))
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-read-progress"));
if (!await _readerService.SaveReadingProgress(progressDto, userId))
{
return BadRequest(await _localizationService.Translate(userId, "generic-read-progress"));
}
return Ok(true);
}
@@ -627,7 +629,7 @@ public class ReaderController : BaseApiController
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
if (user == null) return Unauthorized();
if (user.Bookmarks == null) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
if (user.Bookmarks == null || user.Bookmarks.Count == 0) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
try
{
@@ -667,7 +669,7 @@ public class ReaderController : BaseApiController
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Bookmarks);
if (user == null) return Unauthorized();
if (user.Bookmarks == null) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
if (user.Bookmarks == null || user.Bookmarks.Count == 0) return Ok(await _localizationService.Translate(User.GetUserId(), "nothing-to-do"));
try
{
@@ -882,7 +884,7 @@ public class ReaderController : BaseApiController
{
var userId = User.GetUserId();
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId);
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, userId);
if (series == null || chapter == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-error"));
// Patch in the reading progress
+1 -1
View File
@@ -180,7 +180,7 @@ public class SeriesController : BaseApiController
[HttpGet("chapter")]
public async Task<ActionResult<ChapterDto>> GetChapter(int chapterId)
{
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, User.GetUserId());
if (chapter == null) return NoContent();
return Ok(await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter));
}
+3
View File
@@ -71,6 +71,9 @@ public class SettingsController : BaseApiController
public async Task<ActionResult<ServerSettingDto>> GetSettings()
{
var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
// Do not send OIDC secret to user
settingsDto.OidcConfig.Secret = "*".Repeat(settingsDto.OidcConfig.Secret.Length);
return Ok(settingsDto);
}
+1
View File
@@ -109,6 +109,7 @@ public class UsersController : BaseApiController
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
existingPreferences.CollapseSeriesRelationships = preferencesDto.CollapseSeriesRelationships;
existingPreferences.ShareReviews = preferencesDto.ShareReviews;
existingPreferences.ColorScapeEnabled = preferencesDto.ColorScapeEnabled;
existingPreferences.BookReaderHighlightSlots = preferencesDto.BookReaderHighlightSlots;
if (await _licenseService.HasActiveLicense())