Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Koreader Sync (Canary) #3311

Open
wants to merge 7 commits into
base: canary
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions API.Benchmark/API.Benchmark.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,10 @@
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<None Update="Data\AesopsFables.epub">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
Binary file added API.Benchmark/Data/AesopsFables.epub
Binary file not shown.
44 changes: 44 additions & 0 deletions API.Benchmark/KoreaderHashBenchmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using API.Helpers.Builders;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using API.Entities.Enums;

namespace API.Benchmark
{
[StopOnFirstError]
[MemoryDiagnoser]
[RankColumn]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
[SimpleJob(launchCount: 1, warmupCount: 5, invocationCount: 20)]
public class KoreaderHashBenchmark
{
private const string sourceEpub = "./Data/AesopsFables.epub";

[Benchmark(Baseline = true)]
public void TestBuildManga_baseline()
{
var file = new MangaFileBuilder(sourceEpub, MangaFormat.Epub)
.Build();
if (file == null)
{
throw new Exception("Failed to build manga file");
}
}

[Benchmark]
public void TestBuildManga_withHash()
{
var file = new MangaFileBuilder(sourceEpub, MangaFormat.Epub)
.WithHash()
.Build();
if (file == null)
{
throw new Exception("Failed to build manga file");
}
}
}
}
10 changes: 8 additions & 2 deletions API.Tests/API.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="21.0.29" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" Version="21.0.29" />
<PackageReference Include="xunit" Version="2.9.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
Expand All @@ -35,4 +35,10 @@
<None Remove="Extensions\Test Data\modified on run.txt" />
</ItemGroup>

<ItemGroup>
<None Update="Data\AesopsFables.epub">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
Binary file added API.Tests/Data/AesopsFables.epub
Binary file not shown.
60 changes: 60 additions & 0 deletions API.Tests/Helpers/KoreaderHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using API.DTOs.Koreader;
using API.DTOs.Progress;
using API.Helpers;
using System.Runtime.CompilerServices;
using Xunit;

namespace API.Tests.Helpers;


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.PageNum = page;
var actual = EmptyProgressDto();

KoreaderHelper.UpdateProgressDto(actual, koreaderPosition);
Assert.Equal(expected.BookScrollId, 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")]
public void GetKoreaderPosition(string scrollId, int page, string koreaderPosition)
{
var given = EmptyProgressDto();
given.BookScrollId = scrollId;
given.PageNum = page;

Assert.Equal(koreaderPosition, KoreaderHelper.GetKoreaderPosition(given));
}

[Theory]
[InlineData("./Data/AesopsFables.epub", "8795ACA4BF264B57C1EEDF06A0CEE688")]
public void GetKoreaderHash(string filePath, string hash)
{
Assert.Equal(KoreaderHelper.HashContents(filePath), hash);
}

private ProgressDto EmptyProgressDto()
{
return new ProgressDto
{
ChapterId = 0,
PageNum = 0,
VolumeId = 0,
SeriesId = 0,
LibraryId = 0
};
}
}
46 changes: 23 additions & 23 deletions API/API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
<LangVersion>latestmajor</LangVersion>
</PropertyGroup>

<!-- <Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">-->
<!-- <Delete Files="../openapi.json" />-->
<!-- <Exec Command="swagger tofile &#45;&#45;output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />-->
<!-- </Target>-->

<Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">
<Delete Files="../openapi.json" />
<Exec Command="swagger tofile &#45;&#45;output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />
</Target>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols>
<ApplicationIcon>../favicon.ico</ApplicationIcon>
Expand Down Expand Up @@ -55,41 +55,41 @@

<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="MailKit" Version="4.7.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
<PackageReference Include="MailKit" Version="4.8.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="Docnet.Core" Version="2.6.0" />
<PackageReference Include="EasyCaching.InMemory" Version="1.9.2" />
<PackageReference Include="ExCSS" Version="4.2.5" />
<PackageReference Include="ExCSS" Version="4.3.0" />
<PackageReference Include="Flurl" Version="3.0.7" />
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Hangfire" Version="1.8.14" />
<PackageReference Include="Hangfire" Version="1.8.15" />
<PackageReference Include="Hangfire.InMemory" Version="1.0.0" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.Storage.SQLite" Version="0.4.2" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.66" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.69" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.15" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Nager.ArticleNumber" Version="1.0.7" />
<PackageReference Include="NetVips" Version="2.4.1" />
<PackageReference Include="NetVips" Version="2.4.2" />
<PackageReference Include="NetVips.Native" Version="8.15.3" />
<PackageReference Include="NReco.Logging.File" Version="1.2.1" />
<PackageReference Include="Serilog" Version="4.0.1" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.2" />
<PackageReference Include="Serilog" Version="4.1.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.2" />
<PackageReference Include="Serilog.Settings.Configuration" Version="8.0.4" />
<PackageReference Include="Serilog.Sinks.AspNetCore.SignalR" Version="0.4.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
Expand All @@ -100,11 +100,11 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.3" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="8.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.1.2" />
<PackageReference Include="System.IO.Abstractions" Version="21.0.29" />
<PackageReference Include="System.Drawing.Common" Version="8.0.8" />
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
<PackageReference Include="VersOne.Epub" Version="3.3.2" />
</ItemGroup>

Expand Down
99 changes: 99 additions & 0 deletions API/Controllers/KoreaderController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Koreader;
using API.Entities;
using API.Services;
using Kavita.Common;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using static System.Net.WebRequestMethods;

namespace API.Controllers;
#nullable enable

/// <summary>
/// The endpoint to interface with Koreader's Progress Sync plugin.
/// </summary>
/// <remarks>
/// Koreader uses a different form of authentication. It stores the username and password in headers.
/// https://github.com/koreader/koreader/blob/master/plugins/kosync.koplugin/KOSyncClient.lua
/// </remarks>
[AllowAnonymous]
public class KoreaderController : BaseApiController
{

private readonly IUnitOfWork _unitOfWork;
private readonly ILocalizationService _localizationService;
private readonly IKoreaderService _koreaderService;
private readonly ILogger<KoreaderController> _logger;

public KoreaderController(IUnitOfWork unitOfWork, ILocalizationService localizationService,
IKoreaderService koreaderService, ILogger<KoreaderController> logger)
{
_unitOfWork = unitOfWork;
_localizationService = localizationService;
_koreaderService = koreaderService;
_logger = logger;
}

// We won't allow users to be created from Koreader. Rather, they
// must already have an account.
/*
[HttpPost("/users/create")]
public IActionResult CreateUser(CreateUserRequest request)
{
}
*/

[HttpGet("{apiKey}/users/auth")]
public async Task<IActionResult> Authenticate(string apiKey)
{
var userId = await GetUserId(apiKey);
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null) return Unauthorized();

return Ok(new { username = user.UserName });
}

/// <summary>
/// Syncs book progress with Kavita. Will attempt to save the underlying reader position if possible.
/// </summary>
/// <param name="apiKey"></param>
/// <param name="request"></param>
/// <returns></returns>
[HttpPut("{apiKey}/syncs/progress")]
public async Task<ActionResult<KoreaderProgressUpdateDto>> UpdateProgress(string apiKey, KoreaderBookDto request)
{
var userId = await GetUserId(apiKey);
await _koreaderService.SaveProgress(request, userId);

return Ok(new KoreaderProgressUpdateDto{ Document = request.Document, Timestamp = DateTime.UtcNow });
}


[HttpGet("{apiKey}/syncs/progress/{ebookHash}")]
public async Task<ActionResult<KoreaderBookDto>> GetProgress(string apiKey, string ebookHash)
{
var userId = await GetUserId(apiKey);
var response = await _koreaderService.GetProgress(ebookHash, userId);
_logger.LogDebug("Koreader response progress: {Progress}", response.Progress);

return Ok(response);
}

private async Task<int> GetUserId(string apiKey)
{
try
{
return await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
}
catch
{
throw new KavitaException(await _localizationService.Get("en", "user-doesnt-exist"));
}
}
}
33 changes: 33 additions & 0 deletions API/DTOs/Koreader/KoreaderBookDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using API.DTOs.Progress;

namespace API.DTOs.Koreader;

/// <summary>
/// This is the interface for receiving and sending updates to Koreader. The only fields
/// that are actually used are the Document and Progress fields.
/// </summary>
public class KoreaderBookDto
{
/// <summary>
/// This is the Koreader hash of the book. It is used to identify the book.
/// </summary>
public string Document { get; set; }
/// <summary>
/// A randomly generated id from the koreader device. Only used to maintain the Koreader interface.
/// </summary>
public string Device_id { get; set; }
/// <summary>
/// The Koreader device name. Only used to maintain the Koreader interface.
/// </summary>
public string Device { get; set; }
/// <summary>
/// Percent progress of the book. Only used to maintain the Koreader interface.
/// </summary>
public float Percentage { get; set; }
/// <summary>
/// An XPath string read by Koreader to determine the location within the epub.
/// Essentially, it is Koreader's equivalent to ProgressDto.BookScrollId.
/// </summary>
/// <seealso cref="ProgressDto.BookScrollId"/>
public string Progress { get; set; }
}
9 changes: 9 additions & 0 deletions API/DTOs/Koreader/KoreaderProgressUpdateDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using System;

namespace API.DTOs.Koreader;

public class KoreaderProgressUpdateDto
{
public string Document { get; set; }
public DateTime Timestamp { get; set; }
}
Loading
Loading