More cleanup, handling edge cases, and todos for original creator.

This commit is contained in:
Joseph Milazzo 2024-10-26 07:21:56 -05:00
parent 9893c9f473
commit 231db28a5e
7 changed files with 117 additions and 40 deletions

View file

@ -21,7 +21,7 @@ public class KoreaderHelperTests
expected.PageNum = page; expected.PageNum = page;
var actual = EmptyProgressDto(); var actual = EmptyProgressDto();
KoreaderHelper.UpdateProgressDto(koreaderPosition, actual); KoreaderHelper.UpdateProgressDto(actual, koreaderPosition);
Assert.Equal(expected.BookScrollId, actual.BookScrollId); Assert.Equal(expected.BookScrollId, actual.BookScrollId);
Assert.Equal(expected.PageNum, actual.PageNum); Assert.Equal(expected.PageNum, actual.PageNum);
} }

View file

@ -13,15 +13,15 @@ using Microsoft.Extensions.Logging;
using static System.Net.WebRequestMethods; using static System.Net.WebRequestMethods;
namespace API.Controllers; namespace API.Controllers;
#nullable enable #nullable enable
/// <summary> /// <summary>
/// The endpoint to interface with Koreader's Progress Sync plugin. /// The endpoint to interface with Koreader's Progress Sync plugin.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Koreader uses a different form of authentication. It stores the username and password in headers. /// 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> /// </remarks>
/// <see cref="https://github.com/koreader/koreader/blob/master/plugins/kosync.koplugin/KOSyncClient.lua"/>
[AllowAnonymous] [AllowAnonymous]
public class KoreaderController : BaseApiController public class KoreaderController : BaseApiController
{ {
@ -59,17 +59,22 @@ public class KoreaderController : BaseApiController
return Ok(new { username = user.UserName }); 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")] [HttpPut("{apiKey}/syncs/progress")]
public async Task<IActionResult> UpdateProgress(string apiKey, KoreaderBookDto request) public async Task<ActionResult<KoreaderProgressUpdateDto>> UpdateProgress(string apiKey, KoreaderBookDto request)
{ {
_logger.LogDebug("Koreader sync progress: {Progress}", request.Progress);
var userId = await GetUserId(apiKey); var userId = await GetUserId(apiKey);
await _koreaderService.SaveProgress(request, userId); await _koreaderService.SaveProgress(request, userId);
return Ok(new { document = request.Document, timestamp = DateTime.UtcNow }); return Ok(new KoreaderProgressUpdateDto{ Document = request.Document, Timestamp = DateTime.UtcNow });
} }
[HttpGet("{apiKey}/syncs/progress/{ebookHash}")] [HttpGet("{apiKey}/syncs/progress/{ebookHash}")]
public async Task<ActionResult<KoreaderBookDto>> GetProgress(string apiKey, string ebookHash) public async Task<ActionResult<KoreaderBookDto>> GetProgress(string apiKey, string ebookHash)
{ {
@ -80,11 +85,6 @@ public class KoreaderController : BaseApiController
return Ok(response); return Ok(response);
} }
/// <summary>
/// Gets the user from the API key
/// </summary>
/// <returns>The user's Id</returns>
private async Task<int> GetUserId(string apiKey) private async Task<int> GetUserId(string apiKey)
{ {
try try

View file

@ -0,0 +1,9 @@
using System;
namespace API.DTOs.Koreader;
public class KoreaderProgressUpdateDto
{
public string Document { get; set; }
public DateTime Timestamp { get; set; }
}

View file

@ -1,5 +1,4 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Entities; using API.Entities;
@ -11,7 +10,7 @@ public interface IMangaFileRepository
{ {
void Update(MangaFile file); void Update(MangaFile file);
Task<IList<MangaFile>> GetAllWithMissingExtension(); Task<IList<MangaFile>> GetAllWithMissingExtension();
Task<MangaFile> GetByKoreaderHash(string hash); Task<MangaFile?> GetByKoreaderHash(string hash);
} }
public class MangaFileRepository : IMangaFileRepository public class MangaFileRepository : IMangaFileRepository
@ -35,9 +34,12 @@ public class MangaFileRepository : IMangaFileRepository
.ToListAsync(); .ToListAsync();
} }
public Task<MangaFile> GetByKoreaderHash(string hash) public async Task<MangaFile?> GetByKoreaderHash(string hash)
{ {
return _context.MangaFile if (string.IsNullOrEmpty(hash)) return null;
.FirstOrDefaultAsync(f => f.KoreaderHash == hash.ToUpper());
return await _context.MangaFile
.FirstOrDefaultAsync(f => !string.IsNullOrEmpty(f.KoreaderHash)
&& f.KoreaderHash.Equals(hash, System.StringComparison.CurrentCultureIgnoreCase));
} }
} }

View file

@ -0,0 +1,42 @@
using API.DTOs.Koreader;
namespace API.Helpers.Builders;
public class KoreaderBookDtoBuilder : IEntityBuilder<KoreaderBookDto>
{
private readonly KoreaderBookDto _dto;
public KoreaderBookDto Build() => _dto;
public KoreaderBookDtoBuilder(string documentHash)
{
_dto = new KoreaderBookDto()
{
Document = documentHash,
Device = "Kavita"
};
}
public KoreaderBookDtoBuilder WithDocument(string documentHash)
{
_dto.Document = documentHash;
return this;
}
public KoreaderBookDtoBuilder WithProgress(string progress)
{
_dto.Progress = progress;
return this;
}
public KoreaderBookDtoBuilder WithPercentage(int? pageNum, int pages)
{
_dto.Percentage = (pageNum ?? 0) / (float) pages;
return this;
}
public KoreaderBookDtoBuilder WithDeviceId(string installId, int userId)
{
_dto.Device_id = installId;
return this;
}
}

View file

@ -66,7 +66,7 @@ public static class KoreaderHelper
return BitConverter.ToString(bytes).Replace("-", string.Empty); return BitConverter.ToString(bytes).Replace("-", string.Empty);
} }
public static void UpdateProgressDto(string koreaderPosition, ProgressDto progress) public static void UpdateProgressDto(ProgressDto progress, string koreaderPosition)
{ {
var path = koreaderPosition.Split('/'); var path = koreaderPosition.Split('/');
if (path.Length < 6) if (path.Length < 6)
@ -94,6 +94,7 @@ public static class KoreaderHelper
{ {
string lastTag; string lastTag;
var koreaderPageNumber = progressDto.PageNum + 1; var koreaderPageNumber = progressDto.PageNum + 1;
if (string.IsNullOrEmpty(progressDto.BookScrollId)) if (string.IsNullOrEmpty(progressDto.BookScrollId))
{ {
lastTag = "a"; lastTag = "a";
@ -103,6 +104,7 @@ public static class KoreaderHelper
var tokens = progressDto.BookScrollId.Split('/'); var tokens = progressDto.BookScrollId.Split('/');
lastTag = tokens[^1].ToLower(); lastTag = tokens[^1].ToLower();
} }
// The format that Koreader accepts as a progress string. It tells Koreader where Kavita last left off. // 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/div/{lastTag}";
} }

View file

@ -1,7 +1,9 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.DTOs.Koreader; using API.DTOs.Koreader;
using API.DTOs.Progress;
using API.Helpers; using API.Helpers;
using API.Helpers.Builders;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace API.Services; namespace API.Services;
@ -16,45 +18,65 @@ public interface IKoreaderService
public class KoreaderService : IKoreaderService public class KoreaderService : IKoreaderService
{ {
private IReaderService _readerService; private readonly IReaderService _readerService;
private IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private ILogger<KoreaderService> _logger; private readonly ILogger<KoreaderService> _logger;
public KoreaderService(IReaderService readerService, IUnitOfWork unitOfWork, public KoreaderService(IReaderService readerService, IUnitOfWork unitOfWork, ILogger<KoreaderService> logger)
ILogger<KoreaderService> logger)
{ {
_readerService = readerService; _readerService = readerService;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_logger = logger; _logger = logger;
} }
/// <summary>
/// Given a Koreader hash, locate the underlying file and generate/update a progress event.
/// </summary>
/// <param name="koreaderBookDto"></param>
/// <param name="userId"></param>
public async Task SaveProgress(KoreaderBookDto koreaderBookDto, int userId) public async Task SaveProgress(KoreaderBookDto koreaderBookDto, int userId)
{ {
_logger.LogDebug("Saving Koreader progress for {UserId}: {KoreaderProgress}", userId, koreaderBookDto.Progress);
var file = await _unitOfWork.MangaFileRepository.GetByKoreaderHash(koreaderBookDto.Document); var file = await _unitOfWork.MangaFileRepository.GetByKoreaderHash(koreaderBookDto.Document);
if (file == null) return;
var userProgressDto = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(file.ChapterId, userId); var userProgressDto = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(file.ChapterId, userId);
if (userProgressDto == null)
{
// TODO: Handle this case
userProgressDto = new ProgressDto()
{
ChapterId = file.ChapterId,
};
}
// Update the bookScrollId if possible
KoreaderHelper.UpdateProgressDto(userProgressDto, koreaderBookDto.Progress);
_logger.LogInformation("Saving Koreader progress to Kavita: {KoreaderProgress}", koreaderBookDto.Progress);
KoreaderHelper.UpdateProgressDto(koreaderBookDto.Progress, userProgressDto);
await _readerService.SaveReadingProgress(userProgressDto, userId); await _readerService.SaveReadingProgress(userProgressDto, userId);
await _unitOfWork.CommitAsync();
} }
/// <summary>
/// Returns a Koreader Dto representing current book and the progress within
/// </summary>
/// <param name="bookHash"></param>
/// <param name="userId"></param>
/// <returns></returns>
public async Task<KoreaderBookDto> GetProgress(string bookHash, int userId) public async Task<KoreaderBookDto> GetProgress(string bookHash, int userId)
{ {
var file = await _unitOfWork.MangaFileRepository.GetByKoreaderHash(bookHash);
var progressDto = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(file.ChapterId, userId);
_logger.LogInformation("Transmitting Kavita progress to Koreader: {KoreaderProgress}", progressDto.BookScrollId);
var koreaderProgress = KoreaderHelper.GetKoreaderPosition(progressDto);
var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
var builder = new KoreaderBookDtoBuilder(bookHash);
return new KoreaderBookDto var file = await _unitOfWork.MangaFileRepository.GetByKoreaderHash(bookHash);
{
Document = bookHash, // TODO: How do we handle when file isn't found by hash?
Device_id = settingsDto.InstallId, if (file == null) return builder.Build();
Device = "Kavita",
Progress = koreaderProgress, var progressDto = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(file.ChapterId, userId);
Percentage = progressDto.PageNum / (float) file.Pages var koreaderProgress = KoreaderHelper.GetKoreaderPosition(progressDto);
};
return builder.WithProgress(koreaderProgress)
.WithPercentage(progressDto?.PageNum, file.Pages)
.WithDeviceId(settingsDto.InstallId, userId) // TODO: Should we generate a hash for UserId + InstallId so that this DeviceId is unique to the user on the server?
.Build();
} }
} }