mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-12-23 05:17:22 -05:00
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:
parent
4b2bcda4a0
commit
23e91b51d9
@ -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;
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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>())
|
||||
|
||||
3946
API/Data/Migrations/20251101152738_OpdsSettings.Designer.cs
generated
Normal file
3946
API/Data/Migrations/20251101152738_OpdsSettings.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
API/Data/Migrations/20251101152738_OpdsSettings.cs
Normal file
29
API/Data/Migrations/20251101152738_OpdsSettings.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
|
||||
14
API/Entities/Enums/UserPreferences/AppUserOpdsPreferences.cs
Normal file
14
API/Entities/Enums/UserPreferences/AppUserOpdsPreferences.cs
Normal 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;
|
||||
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
14
API/Entities/Enums/UserPreferences/KeyBind.cs
Normal file
14
API/Entities/Enums/UserPreferences/KeyBind.cs
Normal 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; }
|
||||
}
|
||||
@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user