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;
var actual = EmptyProgressDto();
KoreaderHelper.UpdateProgressDto(koreaderPosition, actual);
KoreaderHelper.UpdateProgressDto(actual, koreaderPosition);
Assert.Equal(expected.BookScrollId, actual.BookScrollId);
Assert.Equal(expected.PageNum, actual.PageNum);
}

View file

@ -13,15 +13,15 @@ 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>
/// <see cref="https://github.com/koreader/koreader/blob/master/plugins/kosync.koplugin/KOSyncClient.lua"/>
[AllowAnonymous]
public class KoreaderController : BaseApiController
{
@ -59,17 +59,22 @@ public class KoreaderController : BaseApiController
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<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);
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}")]
public async Task<ActionResult<KoreaderBookDto>> GetProgress(string apiKey, string ebookHash)
{
@ -80,11 +85,6 @@ public class KoreaderController : BaseApiController
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)
{
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.Threading.Tasks;
using API.Entities;
@ -11,7 +10,7 @@ public interface IMangaFileRepository
{
void Update(MangaFile file);
Task<IList<MangaFile>> GetAllWithMissingExtension();
Task<MangaFile> GetByKoreaderHash(string hash);
Task<MangaFile?> GetByKoreaderHash(string hash);
}
public class MangaFileRepository : IMangaFileRepository
@ -35,9 +34,12 @@ public class MangaFileRepository : IMangaFileRepository
.ToListAsync();
}
public Task<MangaFile> GetByKoreaderHash(string hash)
public async Task<MangaFile?> GetByKoreaderHash(string hash)
{
return _context.MangaFile
.FirstOrDefaultAsync(f => f.KoreaderHash == hash.ToUpper());
if (string.IsNullOrEmpty(hash)) return null;
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);
}
public static void UpdateProgressDto(string koreaderPosition, ProgressDto progress)
public static void UpdateProgressDto(ProgressDto progress, string koreaderPosition)
{
var path = koreaderPosition.Split('/');
if (path.Length < 6)
@ -94,6 +94,7 @@ public static class KoreaderHelper
{
string lastTag;
var koreaderPageNumber = progressDto.PageNum + 1;
if (string.IsNullOrEmpty(progressDto.BookScrollId))
{
lastTag = "a";
@ -103,6 +104,7 @@ public static class KoreaderHelper
var tokens = progressDto.BookScrollId.Split('/');
lastTag = tokens[^1].ToLower();
}
// The format that Koreader accepts as a progress string. It tells Koreader where Kavita last left off.
return $"/body/DocFragment[{koreaderPageNumber}]/body/div/{lastTag}";
}

View file

@ -1,7 +1,9 @@
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Koreader;
using API.DTOs.Progress;
using API.Helpers;
using API.Helpers.Builders;
using Microsoft.Extensions.Logging;
namespace API.Services;
@ -16,45 +18,65 @@ public interface IKoreaderService
public class KoreaderService : IKoreaderService
{
private IReaderService _readerService;
private IUnitOfWork _unitOfWork;
private ILogger<KoreaderService> _logger;
private readonly IReaderService _readerService;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<KoreaderService> _logger;
public KoreaderService(IReaderService readerService, IUnitOfWork unitOfWork,
ILogger<KoreaderService> logger)
public KoreaderService(IReaderService readerService, IUnitOfWork unitOfWork, ILogger<KoreaderService> logger)
{
_readerService = readerService;
_unitOfWork = unitOfWork;
_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)
{
_logger.LogDebug("Saving Koreader progress for {UserId}: {KoreaderProgress}", userId, koreaderBookDto.Progress);
var file = await _unitOfWork.MangaFileRepository.GetByKoreaderHash(koreaderBookDto.Document);
if (file == null) return;
var userProgressDto = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(file.ChapterId, userId);
_logger.LogInformation("Saving Koreader progress to Kavita: {KoreaderProgress}", koreaderBookDto.Progress);
KoreaderHelper.UpdateProgressDto(koreaderBookDto.Progress, userProgressDto);
await _readerService.SaveReadingProgress(userProgressDto, userId);
await _unitOfWork.CommitAsync();
}
public async Task<KoreaderBookDto> GetProgress(string bookHash, int userId)
if (userProgressDto == null)
{
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();
return new KoreaderBookDto
// TODO: Handle this case
userProgressDto = new ProgressDto()
{
Document = bookHash,
Device_id = settingsDto.InstallId,
Device = "Kavita",
Progress = koreaderProgress,
Percentage = progressDto.PageNum / (float) file.Pages
ChapterId = file.ChapterId,
};
}
// Update the bookScrollId if possible
KoreaderHelper.UpdateProgressDto(userProgressDto, koreaderBookDto.Progress);
await _readerService.SaveReadingProgress(userProgressDto, userId);
}
/// <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)
{
var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
var builder = new KoreaderBookDtoBuilder(bookHash);
var file = await _unitOfWork.MangaFileRepository.GetByKoreaderHash(bookHash);
// TODO: How do we handle when file isn't found by hash?
if (file == null) return builder.Build();
var progressDto = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(file.ChapterId, userId);
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();
}
}