KOReader Progress Sync Fix (again) and OPDS Settings (#4174)

Co-authored-by: Fesaa <77553571+Fesaa@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Joe Milazzo 2025-11-01 12:13:22 -05:00 committed by GitHub
parent 4b2bcda4a0
commit 23e91b51d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 4278 additions and 95 deletions

View File

@ -3,6 +3,7 @@ using System.Linq;
using API.Data.Misc;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.UserPreferences;
using API.Entities.Metadata;
using API.Entities.Person;
using API.Extensions.QueryExtensions;

View File

@ -13,30 +13,44 @@ public class KoreaderHelperTests
[Theory]
[InlineData("/body/DocFragment[11]/body/div/a", 10, null)]
[InlineData("/body/DocFragment[1]/body/div/p[40]", 0, 40)]
[InlineData("/body/DocFragment[8]/body/div/p[28]/text().264", 7, 28)]
public void GetEpubPositionDto(string koreaderPosition, int page, int? pNumber)
{
var expected = EmptyProgressDto();
expected.BookScrollId = pNumber.HasValue ? $"//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/P[{pNumber}]" : null;
expected.BookScrollId = pNumber.HasValue ? $"//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/DIV/P[{pNumber}]" : null;
expected.PageNum = page;
var actual = EmptyProgressDto();
KoreaderHelper.UpdateProgressDto(actual, koreaderPosition);
Assert.Equal(expected.BookScrollId, actual.BookScrollId);
Assert.Equal(expected.BookScrollId?.ToLowerInvariant(), actual.BookScrollId);
Assert.Equal(expected.PageNum, actual.PageNum);
}
[Theory]
[InlineData("/body/DocFragment[8]/body/div/p[28]/text().264", 7, 28)]
public void GetEpubPositionDtoWithExtraXpath(string koreaderPosition, int page, int? pNumber)
{
var expected = EmptyProgressDto();
expected.BookScrollId = pNumber.HasValue ? $"//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/DIV/P[{pNumber}]/text().264" : null;
expected.PageNum = page;
var actual = EmptyProgressDto();
KoreaderHelper.UpdateProgressDto(actual, koreaderPosition);
Assert.Equal(expected.BookScrollId?.ToLowerInvariant(), actual.BookScrollId);
Assert.Equal(expected.PageNum, actual.PageNum);
}
[Theory]
[InlineData("//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/P[20]", 5, "/body/DocFragment[6]/body/div/p[20]")]
[InlineData(null, 10, "/body/DocFragment[11]/body/div/a")]
[InlineData("//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/P[20]", 5, "/body/DocFragment[6]/body/p[20]")]
[InlineData(null, 10, "/body/DocFragment[11]/body/a")] // I've not seen a null/just an a from Koreader in testing
public void GetKoreaderPosition(string scrollId, int page, string koreaderPosition)
{
var given = EmptyProgressDto();
given.BookScrollId = scrollId;
given.PageNum = page;
Assert.Equal(koreaderPosition, KoreaderHelper.GetKoreaderPosition(given));
Assert.Equal(koreaderPosition.ToUpperInvariant(), KoreaderHelper.GetKoreaderPosition(given).ToUpperInvariant());
}
[Theory]
@ -46,7 +60,7 @@ public class KoreaderHelperTests
Assert.Equal(KoreaderHelper.HashContents(filePath), hash);
}
private ProgressDto EmptyProgressDto()
private static ProgressDto EmptyProgressDto()
{
return new ProgressDto
{

View File

@ -241,6 +241,37 @@ public class OpdsServiceTests(ITestOutputHelper testOutputHelper) : AbstractDbTe
Assert.StartsWith("Continue Reading from", feed.Entries.First().Title);
}
[Fact]
public async Task ContinuePoint_WithProgress_NotEnabled()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, readerService) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork);
// Disable Continue Point
var user2 = await unitOfWork.UserRepository.GetUserByIdAsync(user.Id, AppUserIncludes.UserPreferences);
user2.UserPreferences.OpdsPreferences.IncludeContinueFrom = false;
unitOfWork.UserRepository.Update(user2);
await unitOfWork.CommitAsync();
var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1);
await readerService.MarkChaptersAsRead(user, 1, [firstChapter]);
await unitOfWork.CommitAsync();
var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
EntityId = 1,
PageNumber = 0
});
Assert.Equal(2, feed.Entries.Count);
Assert.False(feed.Entries.First().Title.StartsWith("Continue Reading from"));
}
[Fact]
public async Task ContinuePoint_DoesntExist_WhenNoProgress()
{
@ -309,6 +340,48 @@ public class OpdsServiceTests(ITestOutputHelper testOutputHelper) : AbstractDbTe
Assert.Contains(expectedIcon, feed.Entries[entryIndex].Title);
}
[Fact]
public async Task ReadingIcon_NotEnabled()
{
var (unitOfWork, context, mapper) = await CreateDatabase();
var (opdsService, readerService) = SetupService(unitOfWork, mapper);
var user = await SetupSeriesAndUser(context, unitOfWork);
// Disable Continue Point
var user2 = await unitOfWork.UserRepository.GetUserByIdAsync(user.Id, AppUserIncludes.UserPreferences);
user2.UserPreferences.OpdsPreferences.EmbedProgressIndicator = false;
unitOfWork.UserRepository.Update(user2);
await unitOfWork.CommitAsync();
var firstChapter = await unitOfWork.ChapterRepository.GetChapterAsync(1);
Assert.NotNull(firstChapter);
await readerService.SaveReadingProgress(new ProgressDto
{
VolumeId = firstChapter.VolumeId,
ChapterId = firstChapter.Id,
PageNum = 2,
SeriesId = 1,
LibraryId = 1,
BookScrollId = null,
LastModifiedUtc = default
}, user.Id);
var feed = await opdsService.GetSeriesDetail(new OpdsItemsFromEntityIdRequest
{
ApiKey = user.ApiKey,
Prefix = OpdsService.DefaultApiPrefix,
BaseUrl = string.Empty,
UserId = user.Id,
EntityId = 1,
PageNumber = 0
});
List<string> icons = [OpdsService.NoReadingProgressIcon, OpdsService.QuarterReadingProgressIcon, OpdsService.HalfReadingProgressIcon, OpdsService.AboveHalfReadingProgressIcon, OpdsService.FullReadingProgressIcon];
Assert.NotEmpty(feed.Entries);
Assert.DoesNotContain(feed.Entries, e => icons.Any(icon => e.Title.Contains(icon)));
}
#endregion
#region Misc

View File

@ -130,6 +130,8 @@ public class UsersController : BaseApiController
.Where(l => allLibs.Contains(l)).ToList();
existingPreferences.SocialPreferences = preferencesDto.SocialPreferences;
existingPreferences.OpdsPreferences = preferencesDto.OpdsPreferences;
if (await _licenseService.HasActiveLicense())
{
existingPreferences.AniListScrobblingEnabled = preferencesDto.AniListScrobblingEnabled;

View File

@ -55,7 +55,12 @@ public sealed record UserPreferencesDto
#region Social
/// <inheritdoc cref="AppUserPreferences.SocialPreferences"/>
[Required]
public AppUserSocialPreferences SocialPreferences { get; set; } = new();
#endregion
/// <inheritdoc cref="AppUserPreferences.OpdsPreferences"/>
[Required]
public AppUserOpdsPreferences OpdsPreferences { get; set; } = new();
}

View File

@ -312,6 +312,12 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
.HasColumnType("TEXT")
.HasDefaultValue(new AppUserSocialPreferences());
builder.Entity<AppUserPreferences>()
.Property(a => a.OpdsPreferences)
.HasJsonConversion(new AppUserOpdsPreferences())
.HasColumnType("TEXT")
.HasDefaultValue(new AppUserOpdsPreferences());
builder.Entity<AppUserAnnotation>()
.Property(a => a.Likes)
.HasJsonConversion(new HashSet<int>())

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class OpdsSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "OpdsPreferences",
table: "AppUserPreferences",
type: "TEXT",
nullable: true,
defaultValue: "{\"EmbedProgressIndicator\":true,\"IncludeContinueFrom\":true}");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "OpdsPreferences",
table: "AppUserPreferences");
}
}
}

View File

@ -599,6 +599,11 @@ namespace API.Data.Migrations
b.Property<bool>("NoTransitions")
.HasColumnType("INTEGER");
b.Property<string>("OpdsPreferences")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("{\"EmbedProgressIndicator\":true,\"IncludeContinueFrom\":true}");
b.Property<int>("PageSplitOption")
.HasColumnType("INTEGER");

View File

@ -0,0 +1,14 @@
namespace API.Entities.Enums.UserPreferences;
public class AppUserOpdsPreferences
{
/// <summary>
/// Embed Progress Indicator in Title
/// </summary>
public bool EmbedProgressIndicator { get; set; } = true;
/// <summary>
/// Insert a "Continue From X" entry in OPDS fields to avoid finding your last reading point (Emulates Kavita's Continue button)
/// </summary>
public bool IncludeContinueFrom { get; set; } = true;
}

View File

@ -1,5 +1,4 @@
#nullable enable
using System;
using System;
using System.Collections.Generic;
using API.Data;
using API.Entities.Enums;
@ -212,6 +211,12 @@ public class AppUserPreferences
/// <remarks>Saved as a JSON obj in the DB</remarks>
public AppUserSocialPreferences SocialPreferences { get; set; } = new();
/// <summary>
/// The opds preferences of the AppUser
/// </summary>
/// <remarks>Saved as a JSON obj in the DB</remarks>
public AppUserOpdsPreferences OpdsPreferences { get; set; } = new();
#endregion
@ -219,47 +224,3 @@ public class AppUserPreferences
public AppUser AppUser { get; set; } = null!;
public int AppUserId { get; set; }
}
public class AppUserSocialPreferences
{
/// <summary>
/// UI Site Global Setting: Should series reviews be shared with all users in the server
/// </summary>
public bool ShareReviews { get; set; } = false;
/// <summary>
/// UI Site Global Setting: Share your annotations with other users
/// </summary>
public bool ShareAnnotations { get; set; } = false;
/// <summary>
/// UI Site Global Setting: See other users' annotations while reading
/// </summary>
public bool ViewOtherAnnotations { get; set; } = false;
/// <summary>
/// UI Site Global Setting: For which libraries should social features be enabled
/// </summary>
/// <remarks>Empty array means all, disable specific social features to opt out everywhere</remarks>
public IList<int> SocialLibraries { get; set; } = [];
/// <summary>
/// UI Site Global Setting: Highest age rating for which social features are enabled
/// </summary>
public AgeRating SocialMaxAgeRating { get; set; } = AgeRating.NotApplicable;
/// <summary>
/// UI Site Global Setting: Enable social features for unknown age ratings
/// </summary>
public bool SocialIncludeUnknowns { get; set; } = true;
}
public sealed record KeyBind
{
public string Key { get; set; }
public bool Control { get; set; }
public bool Shift { get; set; }
public bool Meta { get; set; }
public bool Alt { get; set; }
public IList<string>? ControllerSequence { get; set; }
}

View File

@ -0,0 +1,37 @@
using System.Collections.Generic;
namespace API.Entities.Enums.UserPreferences;
public class AppUserSocialPreferences
{
/// <summary>
/// UI Site Global Setting: Should series reviews be shared with all users in the server
/// </summary>
public bool ShareReviews { get; set; } = false;
/// <summary>
/// UI Site Global Setting: Share your annotations with other users
/// </summary>
public bool ShareAnnotations { get; set; } = false;
/// <summary>
/// UI Site Global Setting: See other users' annotations while reading
/// </summary>
public bool ViewOtherAnnotations { get; set; } = false;
/// <summary>
/// UI Site Global Setting: For which libraries should social features be enabled
/// </summary>
/// <remarks>Empty array means all, disable specific social features to opt out everywhere</remarks>
public IList<int> SocialLibraries { get; set; } = [];
/// <summary>
/// UI Site Global Setting: Highest age rating for which social features are enabled
/// </summary>
public AgeRating SocialMaxAgeRating { get; set; } = AgeRating.NotApplicable;
/// <summary>
/// UI Site Global Setting: Enable social features for unknown age ratings
/// </summary>
public bool SocialIncludeUnknowns { get; set; } = true;
}

View File

@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace API.Entities.Enums.UserPreferences;
#nullable enable
public sealed record KeyBind
{
public string Key { get; set; }
public bool Control { get; set; }
public bool Shift { get; set; }
public bool Meta { get; set; }
public bool Alt { get; set; }
public IList<string>? ControllerSequence { get; set; }
}

View File

@ -3,6 +3,7 @@ using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
namespace API.Helpers;
@ -70,16 +71,29 @@ public static class KoreaderHelper
public static void UpdateProgressDto(ProgressDto progress, string koreaderPosition)
{
// #_doc_fragment26
string docNumber;
if (koreaderPosition.StartsWith("#_doc_fragment"))
{
docNumber = koreaderPosition.Replace("#_doc_fragment", string.Empty);
progress.PageNum = int.Parse(docNumber) - 1;
return;
}
var path = koreaderPosition.Split('/');
if (path.Length < 6)
{
return;
}
var docNumber = path[2].Replace("DocFragment[", string.Empty).Replace("]", string.Empty);
docNumber = path[2].Replace("DocFragment[", string.Empty).Replace("]", string.Empty);
progress.PageNum = int.Parse(docNumber) - 1;
var lastPart = koreaderPosition.Split("/body/")[^1];
var lastTag = path[5].ToUpper();
// TODO: Enhance this code: /body/DocFragment[27]/body/section/p[3]/text().229 -> p[3] but we probably can get more
if (lastTag == "A")
{
progress.BookScrollId = null;
@ -87,27 +101,28 @@ public static class KoreaderHelper
else
{
// The format that Kavita accepts as a progress string. It tells Kavita where Koreader last left off.
progress.BookScrollId = $"//html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/{lastTag}";
progress.BookScrollId = $"//html[1]/{BookService.BookReaderBodyScope[2..].ToLowerInvariant()}/{lastPart}";
}
}
public static string GetKoreaderPosition(ProgressDto progressDto)
{
string lastTag;
string nonBodyTag;
var koreaderPageNumber = progressDto.PageNum + 1;
if (string.IsNullOrEmpty(progressDto.BookScrollId))
{
lastTag = "a";
nonBodyTag = "a";
}
else
{
var tokens = progressDto.BookScrollId.Split('/');
lastTag = tokens[^1].ToLower();
// What we Store: //html[1]/BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]/section/p[62]/text().0
// What we Need to send back: section/p[62]/text().0
nonBodyTag = progressDto.BookScrollId.Replace("//html[1]/", "//", StringComparison.InvariantCultureIgnoreCase).Replace(BookService.BookReaderBodyScope + "/", string.Empty, StringComparison.InvariantCultureIgnoreCase);
}
// The format that Koreader accepts as a progress string. It tells Koreader where Kavita last left off.
return $"/body/DocFragment[{koreaderPageNumber}]/body/div/{lastTag}";
return $"/body/DocFragment[{koreaderPageNumber}]/body/{nonBodyTag}";
}
}

View File

@ -77,6 +77,7 @@ public partial class BookService : IBookService
private static readonly RecyclableMemoryStreamManager StreamManager = new ();
private const string CssScopeClass = ".book-content";
private const string BookApiUrl = "book-resources?file=";
public const string BookReaderBodyScope = "//BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]";
private readonly PdfComicInfoExtractor _pdfComicInfoExtractor;
/// <summary>
@ -327,7 +328,7 @@ public partial class BookService : IBookService
{
var unscopedSelector = bookmark.BookScrollId!
.Replace(
"//BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]",
BookReaderBodyScope,
"//BODY").ToLowerInvariant();
var elem = doc.DocumentNode.SelectSingleNode(unscopedSelector);
if (elem == null) continue;

View File

@ -61,7 +61,9 @@ public class KoreaderService : IKoreaderService
};
}
// Update the bookScrollId if possible
var reportedProgress = koreaderBookDto.progress;
KoreaderHelper.UpdateProgressDto(userProgressDto, koreaderBookDto.progress);
_logger.LogDebug("Converting KOReader progress from {ReportedProgress} to {ScopedProgress}", reportedProgress.Sanitize(), userProgressDto.BookScrollId?.Sanitize());
await _readerService.SaveReadingProgress(userProgressDto, userId);
}
@ -81,7 +83,10 @@ public class KoreaderService : IKoreaderService
if (file == null) throw new KavitaException(await _localizationService.Translate(userId, "file-missing"));
var progressDto = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(file.ChapterId, userId);
var originalScrollId = progressDto?.BookScrollId;
var koreaderProgress = KoreaderHelper.GetKoreaderPosition(progressDto);
_logger.LogDebug("Converting KOReader progress from {KavitaProgress} to {KOReaderProgress}", originalScrollId?.Sanitize() ?? string.Empty, progressDto?.BookScrollId?.Sanitize() ?? string.Empty);
return new KoreaderBookDtoBuilder(bookHash).WithProgress(koreaderProgress)
.WithPercentage(progressDto?.PageNum, file.Pages)

View File

@ -17,6 +17,7 @@ using API.DTOs.ReadingLists;
using API.DTOs.Search;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Enums.UserPreferences;
using API.Exceptions;
using API.Helpers;
using AutoMapper;
@ -597,17 +598,17 @@ public class OpdsService : IOpdsService
// Check if there is reading progress or not, if so, inject a "continue-reading" item
var anyProgress = await _unitOfWork.ReadingListRepository.AnyUserReadingProgressAsync(readingListId, userId);
if (anyProgress)
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences);
if (user!.UserPreferences.OpdsPreferences.IncludeContinueFrom && anyProgress)
{
var firstReadReadingListItem = await _unitOfWork.ReadingListRepository.GetContinueReadingPoint(readingListId, userId);
if (firstReadReadingListItem != null && request.PageNumber == FirstPageNumber)
{
await AddContinueReadingPoint(firstReadReadingListItem, feed, request);
await AddContinueReadingPoint(firstReadReadingListItem, feed, request, user.UserPreferences.OpdsPreferences);
}
}
foreach (var item in items)
{
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(item.ChapterId, userId);
@ -617,7 +618,7 @@ public class OpdsService : IOpdsService
{
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(item.SeriesId, userId);
feed.Entries.Add(await CreateChapterWithFile(item.SeriesId, item.VolumeId, item.ChapterId,
chapterDto.Files.First(), series!, chapterDto, request));
chapterDto.Files.First(), series!, chapterDto, request, user.UserPreferences.OpdsPreferences));
}
else
{
@ -646,10 +647,11 @@ public class OpdsService : IOpdsService
// 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 user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences);
if (user!.UserPreferences.OpdsPreferences.IncludeContinueFrom && anyUserProgress)
{
var chapterDto = await _readerService.GetContinuePoint(seriesId, userId);
await AddContinueReadingPoint(seriesId, chapterDto, feed, request);
await AddContinueReadingPoint(seriesId, chapterDto, feed, request, user.UserPreferences.OpdsPreferences);
}
@ -672,7 +674,7 @@ public class OpdsService : IOpdsService
if (!fileDict.TryAdd(mangaFile.Id, 0)) continue;
feed.Entries.Add(await CreateChapterWithFile(seriesId, volume.Id, chapterId, _mapper.Map<MangaFileDto>(mangaFile), series,
chapterDto, request));
chapterDto, request, user.UserPreferences.OpdsPreferences));
}
}
}
@ -692,7 +694,7 @@ public class OpdsService : IOpdsService
// 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(seriesId, chapter.VolumeId, chapter.Id, _mapper.Map<MangaFileDto>(mangaFile), series,
chapterDto, request));
chapterDto, request, user.UserPreferences.OpdsPreferences));
}
}
@ -706,7 +708,7 @@ public class OpdsService : IOpdsService
if (!fileDict.TryAdd(mangaFile.Id, 0)) continue;
feed.Entries.Add(await CreateChapterWithFile(seriesId, special.VolumeId, special.Id, _mapper.Map<MangaFileDto>(mangaFile), series,
chapterDto, request));
chapterDto, request, user.UserPreferences.OpdsPreferences));
}
}
@ -740,17 +742,18 @@ public class OpdsService : IOpdsService
// Check if there is reading progress or not, if so, inject a "continue-reading" item
var firstChapterWithProgress = chapterDtos.FirstOrDefault(i => i.PagesRead > 0 && i.PagesRead != i.Pages) ??
chapterDtos.FirstOrDefault(i => i.PagesRead == 0 && i.PagesRead != i.Pages);
if (firstChapterWithProgress != null && request.PageNumber == FirstPageNumber)
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences);
if (user!.UserPreferences.OpdsPreferences.IncludeContinueFrom && firstChapterWithProgress != null && request.PageNumber == FirstPageNumber)
{
var chapterDto = await _readerService.GetContinuePoint(seriesId, userId);
await AddContinueReadingPoint(seriesId, chapterDto, feed, request);
await AddContinueReadingPoint(seriesId, chapterDto, feed, request, user.UserPreferences.OpdsPreferences);
}
foreach (var chapterDto in chapterDtos)
{
foreach (var mangaFile in chapterDto.Files)
{
feed.Entries.Add(await CreateChapterWithFile(seriesId, volumeId, chapterDto.Id, mangaFile, series, chapterDto!, request));
feed.Entries.Add(await CreateChapterWithFile(seriesId, volumeId, chapterDto.Id, mangaFile, series, chapterDto, request, user.UserPreferences.OpdsPreferences));
}
}
@ -785,9 +788,10 @@ public class OpdsService : IOpdsService
$"{apiKey}/series/{seriesId}/volume/{volumeId}/chapter/{chapterId}", apiKey, prefix);
SetFeedId(feed, $"series-{series.Id}-volume-{volumeId}-{_seriesService.FormatChapterName(userId, libraryType)}-{chapterId}-files");
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences);
foreach (var mangaFile in chapter.Files)
{
feed.Entries.Add(await CreateChapterWithFile(seriesId, volumeId, chapterId, mangaFile, series, chapter, request));
feed.Entries.Add(await CreateChapterWithFile(seriesId, volumeId, chapterId, mangaFile, series, chapter, request, user!.UserPreferences.OpdsPreferences));
}
return feed;
@ -1147,17 +1151,20 @@ public class OpdsService : IOpdsService
}
private async Task<FeedEntry> CreateContinueReadingFromFile(int seriesId, int volumeId, int chapterId,
MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, IOpdsRequest request)
MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, IOpdsRequest request, AppUserOpdsPreferences pref)
{
var entry = await CreateChapterWithFile(seriesId, volumeId, chapterId, mangaFile, series, chapter, request);
var entry = await CreateChapterWithFile(seriesId, volumeId, chapterId, mangaFile, series, chapter, request, pref);
if (pref.EmbedProgressIndicator)
{
entry.Title = await _localizationService.Translate(request.UserId, "opds-continue-reading-title", entry.Title);
}
return entry;
}
private async Task<FeedEntry> CreateChapterWithFile(int seriesId, int volumeId, int chapterId,
MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, IOpdsRequest request)
MangaFileDto mangaFile, SeriesDto series, ChapterDto chapter, IOpdsRequest request, AppUserOpdsPreferences pref)
{
var fileSize =
mangaFile.Bytes > 0 ? DirectoryService.GetHumanReadableBytes(mangaFile.Bytes) :
@ -1229,7 +1236,10 @@ public class OpdsService : IOpdsService
}
// Patch in reading status on the item (as OPDS is seriously lacking)
if (pref.EmbedProgressIndicator)
{
entry.Title = $"{GetReadingProgressIcon(chapter.PagesRead, chapter.Pages)} {entry.Title}";
}
return entry;
}
@ -1284,24 +1294,24 @@ public class OpdsService : IOpdsService
};
}
private async Task AddContinueReadingPoint(int seriesId, ChapterDto chapterDto, Feed feed, IOpdsRequest request)
private async Task AddContinueReadingPoint(int seriesId, ChapterDto chapterDto, Feed feed, IOpdsRequest request, AppUserOpdsPreferences pref)
{
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, request.UserId);
if (chapterDto is {Files.Count: 1})
{
feed.Entries.Add(await CreateContinueReadingFromFile(seriesId, chapterDto.VolumeId, chapterDto.Id,
chapterDto.Files.First(), series!, chapterDto, request));
chapterDto.Files.First(), series!, chapterDto, request, pref));
}
}
private async Task AddContinueReadingPoint(ReadingListItemDto firstReadReadingListItem, Feed feed, IOpdsRequest request)
private async Task AddContinueReadingPoint(ReadingListItemDto firstReadReadingListItem, Feed feed, IOpdsRequest request, AppUserOpdsPreferences pref)
{
var chapterDto = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(firstReadReadingListItem.ChapterId, request.UserId);
var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(firstReadReadingListItem.SeriesId, request.UserId);
if (chapterDto is {Files.Count: 1})
{
feed.Entries.Add(await CreateContinueReadingFromFile(firstReadReadingListItem.SeriesId, firstReadReadingListItem.VolumeId,
firstReadReadingListItem.ChapterId, chapterDto.Files.First(), series!, chapterDto, request));
firstReadReadingListItem.ChapterId, chapterDto.Files.First(), series!, chapterDto, request, pref));
}
}
}

View File

@ -286,7 +286,7 @@ public class ReaderService : IReaderService
_unitOfWork.AppUserProgressRepository.Update(userProgress);
}
_logger.LogDebug("Saving Progress on Chapter {ChapterId} from Series {SeriesId} to {PageNum}", progressDto.ChapterId, progressDto.SeriesId, progressDto.PageNum);
_logger.LogDebug("Saving Progress on Series {SeriesId}, Chapter {ChapterId} to Page {PageNum}", progressDto.SeriesId, progressDto.ChapterId, progressDto.PageNum);
userProgress?.MarkModified();
if (!_unitOfWork.HasChanges() || await _unitOfWork.CommitAsync())

View File

@ -883,7 +883,7 @@ public class ProcessSeries : IProcessSeries
private void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info, bool forceUpdate = false)
{
chapter.Files ??= new List<MangaFile>();
chapter.Files ??= [];
var existingFile = chapter.Files.SingleOrDefault(f => f.FilePath == info.FullFilePath);
var fileInfo = _directoryService.FileSystem.FileInfo.New(info.FullFilePath);
if (existingFile != null)

View File

@ -25,6 +25,8 @@ export interface Preferences {
// Social
socialPreferences: SocialPreferences;
opdsPreferences: OpdsPreferences;
}
export interface SocialPreferences {
@ -36,6 +38,11 @@ export interface SocialPreferences {
socialIncludeUnknowns: boolean;
}
export interface OpdsPreferences {
embedProgressIndicator: boolean;
includeContinueFrom: boolean;
}
export interface KeyBind {
meta?: boolean;
control?: boolean;
@ -60,3 +67,8 @@ export enum KeyBindTarget {
Escape = 'Escape',
}
export interface OpdsPreferences {
embedProgressIndicator: boolean;
includeContinueFrom: boolean;
}

View File

@ -201,7 +201,33 @@
</div>
</ng-container>
<div class="setting-section-break"></div>
<h4>{{t('opds-settings-title')}}</h4>
<ng-container formGroupName="opdsPreferences">
<div class="row g-0 mt-4">
<app-setting-switch [title]="t('embed-progress-indicator-label')" [subtitle]="t('embed-progress-indicator-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input type="checkbox" role="switch" id="embed-progress-indicator"
formControlName="embedProgressIndicator" class="form-check-input"
aria-labelledby="auto-close-label">
</div>
</ng-template>
</app-setting-switch>
</div>
<div class="row g-0 mt-4">
<app-setting-switch [title]="t('include-continue-from-label')" [subtitle]="t('include-continue-from-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input type="checkbox" role="switch" id="include-continue-from"
formControlName="includeContinueFrom" class="form-check-input">
</div>
</ng-template>
</app-setting-switch>
</div>
</ng-container>
@if (licenseService.hasValidLicense$ | async) {
<div class="setting-section-break"></div>

View File

@ -1,13 +1,4 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
DestroyRef, effect,
inject,
OnInit,
signal
} from '@angular/core';
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit, signal} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
import {Preferences} from "../../_models/preferences/preferences";
import {AccountService} from "../../_services/account.service";
@ -60,6 +51,11 @@ type UserPreferencesForm = FormGroup<{
socialMaxAgeRating: FormControl<AgeRating>,
socialIncludeUnknowns: FormControl<boolean>,
}>,
opdsPreferences: FormGroup<{
embedProgressIndicator: FormControl<boolean>,
includeContinueFrom: FormControl<boolean>,
}>
}>
@Component({
@ -176,6 +172,11 @@ export class ManageUserPreferencesComponent implements OnInit {
socialMaxAgeRating: this.fb.control<AgeRating>(pref.socialPreferences.socialMaxAgeRating),
socialIncludeUnknowns: this.fb.control<boolean>(pref.socialPreferences.socialIncludeUnknowns),
}),
opdsPreferences: this.fb.group({
embedProgressIndicator: this.fb.control<boolean>(pref.opdsPreferences.embedProgressIndicator),
includeContinueFrom: this.fb.control<boolean>(pref.opdsPreferences.includeContinueFrom),
})
});
// Automatically save settings as we edit them

View File

@ -196,6 +196,7 @@
"global-settings-title": "Global Settings",
"social-settings-title": "Social Settings",
"opds-settings-title": "OPDS Settings",
"page-layout-mode-label": "Page Layout Mode",
"page-layout-mode-tooltip": "Show items as cards or list view on Series Detail page.",
"locale-label": "Locale",
@ -234,6 +235,11 @@
"social-include-unknowns-label": "Include unknowns",
"social-include-unknowns-tooltip": "Enable social features for series and chapters with an unknown age rating",
"embed-progress-indicator-label": "Embed Progress Indicator",
"embed-progress-indicator-tooltip": "Embed Progress Indicator in Title",
"include-continue-from-label": "Include Continue From Entry",
"include-continue-from-tooltip": "Insert a \"Continue From X\" entry in OPDS fields to avoid finding your last reading point (Emulates Kavita's Continue button)",
"clients-opds-alert": "OPDS is not enabled on this server. This will not affect Mihon users.",
"clients-opds-description": "All 3rd Party clients will either use the API key or the Connection Url below. These are like passwords, keep it private.",
"clients-api-key-tooltip": "The API key is like a password. Resetting it will invalidate any existing clients.",