├── .dockerignore ├── .github └── workflows │ └── publish-docker.yml ├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── Application ├── LibrarySync │ └── CalibreWebSync.cs ├── Setup.cs ├── Syncs │ ├── GetProgress │ │ ├── GetProgressHandler.cs │ │ └── GetProgressResponse.cs │ └── UpdateProgress │ │ ├── UpdateProgressCommand.cs │ │ ├── UpdateProgressHandler.cs │ │ └── UpdateProgressResponse.cs └── Users │ ├── Auth │ └── AuthHandler.cs │ └── CreateUser │ ├── CreateUserCommand.cs │ └── CreateUserHandler.cs ├── Configuration ├── AuthOperationsFilter.cs ├── AuthenticationService.cs ├── CustomAuthenticationHandler.cs ├── SnakeCaseNamingPolicy.cs ├── SnakeCaseSchemaFilter.cs └── StringExtensions.cs ├── Dockerfile ├── Domain ├── Book.cs ├── Library.cs ├── Repositories │ └── IUserRepository.cs ├── Services │ └── HashGenerator.cs └── User.cs ├── Infrastructure ├── Repositories │ └── UserRepository.cs ├── Services │ ├── CalibreWebService.cs │ └── Models │ │ ├── CalibreFeed.cs │ │ └── CalibreFeedRow.cs └── Setup.cs ├── LICENSE ├── Program.cs ├── Properties └── launchSettings.json ├── README.md ├── Sync.csproj ├── appsettings.Development.json └── appsettings.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | .vscode/ 3 | bin/ 4 | obj/ 5 | .gitignore 6 | appsettings.Development.json 7 | LICENSE 8 | README.md -------------------------------------------------------------------------------- /.github/workflows/publish-docker.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker Image 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Build the Docker image 14 | run: docker build . -t vincentbitter/koreader-calibre-web-sync:latest 15 | 16 | - name: Log into Docker Hub 17 | uses: docker/login-action@v3 18 | with: 19 | username: ${{ secrets.DOCKERHUB_USERNAME }} 20 | password: ${{ secrets.DOCKERHUB_TOKEN }} 21 | 22 | - name: Push the images to Docker Hub 23 | run: docker push vincentbitter/koreader-calibre-web-sync 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /obj 2 | /bin -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | // Use IntelliSense to find out which attributes exist for C# debugging 6 | // Use hover for the description of the existing attributes 7 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md 8 | "name": ".NET Core Launch (web)", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | // If you have changed target frameworks, make sure to update the program path. 13 | "program": "${workspaceFolder}/bin/Debug/net8.0/Sync.dll", 14 | "args": [], 15 | "cwd": "${workspaceFolder}", 16 | "stopAtEntry": false, 17 | // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser 18 | "serverReadyAction": { 19 | "action": "openExternally", 20 | "pattern": "\\bNow listening on:\\s+http://\\S+:([0-9]+)", 21 | "uriFormat": "http://localhost:%s/swagger/index.html" 22 | }, 23 | "env": { 24 | "ASPNETCORE_ENVIRONMENT": "Development", 25 | "ASPNETCORE_URLS": "http://*:5157" 26 | } 27 | }, 28 | { 29 | "name": ".NET Core Attach", 30 | "type": "coreclr", 31 | "request": "attach" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}/Sync.csproj", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | }, 16 | { 17 | "label": "publish", 18 | "command": "dotnet", 19 | "type": "process", 20 | "args": [ 21 | "publish", 22 | "${workspaceFolder}/Sync.csproj", 23 | "/property:GenerateFullPaths=true", 24 | "/consoleloggerparameters:NoSummary" 25 | ], 26 | "problemMatcher": "$msCompile" 27 | }, 28 | { 29 | "label": "watch", 30 | "command": "dotnet", 31 | "type": "process", 32 | "args": [ 33 | "watch", 34 | "run", 35 | "--project", 36 | "${workspaceFolder}/Sync.csproj" 37 | ], 38 | "problemMatcher": "$msCompile" 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /Application/LibrarySync/CalibreWebSync.cs: -------------------------------------------------------------------------------- 1 | 2 | using Sync.Domain; 3 | using Sync.Domain.Repositories; 4 | using Sync.Infrastructure.Services; 5 | 6 | namespace Sync.Application.LibrarySync; 7 | 8 | class CalibreWebSync : BackgroundService 9 | { 10 | private readonly IUserRepository _userRepository; 11 | private readonly CalibreWebService _calibre; 12 | 13 | public CalibreWebSync(IUserRepository userRepository, CalibreWebService calibre) 14 | { 15 | _userRepository = userRepository; 16 | _calibre = calibre; 17 | } 18 | 19 | protected async override Task ExecuteAsync(CancellationToken cancellationToken) 20 | { 21 | while (!cancellationToken.IsCancellationRequested) 22 | { 23 | await SyncBooks(cancellationToken); 24 | await Task.Delay(1000, cancellationToken); 25 | } 26 | } 27 | 28 | private async Task SyncBooks(CancellationToken cancellationToken) 29 | { 30 | var users = _userRepository.GetAll().Where(u => u.Library.NeedsSync()).ToList(); 31 | foreach (var user in users) 32 | { 33 | await SyncBooks(user, cancellationToken); 34 | } 35 | } 36 | 37 | private async Task SyncBooks(User user, CancellationToken cancellationToken) 38 | { 39 | var library = user.Library; 40 | library.MarkAsSynced(); 41 | await UpdateBooksFromRemote(user, library, cancellationToken); 42 | foreach (var book in library.GetBookToSync()) 43 | { 44 | if (!book.CalibreId.HasValue) 45 | throw new ApplicationException("Calibre ID should never be null at this stage"); 46 | try 47 | { 48 | await _calibre.MarkBookAsReadSync(user, book.CalibreId.Value, cancellationToken); 49 | book.Synced(); 50 | } 51 | catch 52 | { 53 | // Ignore exceptions, as there is no user interface to report issues 54 | // todo: add logging 55 | } 56 | } 57 | } 58 | 59 | private async Task UpdateBooksFromRemote(User user, Library library, CancellationToken cancellationToken) 60 | { 61 | var booksInCalibre = await _calibre.GetBooksAsync(user, cancellationToken); 62 | foreach (var book in booksInCalibre) 63 | { 64 | var existing = library.GetBookByHash(book.Hashes); 65 | if (existing != null) 66 | existing.UpdateFromRemote(book); 67 | else 68 | library.Add(book); 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /Application/Setup.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using Microsoft.AspNetCore.Authorization; 3 | using Sync.Application.LibrarySync; 4 | using Sync.Application.Syncs.GetProgress; 5 | using Sync.Application.Syncs.UpdateProgress; 6 | using Sync.Application.Users.Auth; 7 | using Sync.Application.Users.CreteUser; 8 | 9 | namespace Sync.Application; 10 | 11 | public static class Setup 12 | { 13 | public static void AddServices(IServiceCollection services) 14 | { 15 | services.AddHostedService(); 16 | 17 | services.AddSingleton(); 18 | services.AddSingleton(); 19 | services.AddSingleton(); 20 | services.AddSingleton(); 21 | } 22 | 23 | public static void AddRoutes(IEndpointRouteBuilder app) 24 | { 25 | app.MapPost("/users/create", [AllowAnonymous] (CreateUserCommand command, CreateUserHandler handler) => handler.Handle(command)); 26 | app.MapGet("/users/auth", [Authorize] (ClaimsPrincipal user, AuthHandler handler) => handler.Handle()); 27 | app.MapPut("/syncs/progress", [Authorize] (ClaimsPrincipal user, UpdateProgressCommand command, UpdateProgressHandler handler) => handler.Handle(user, command)); 28 | app.MapGet("/syncs/progress/{document}", [Authorize] (ClaimsPrincipal user, string document, GetProgressHandler handler) => handler.Handle(document)); 29 | } 30 | } -------------------------------------------------------------------------------- /Application/Syncs/GetProgress/GetProgressHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Sync.Application.Syncs.GetProgress; 2 | 3 | class GetProgressHandler 4 | { 5 | public GetProgressResponse? Handle(string hash) 6 | { 7 | // return new GetProgressResponse(0, 0, null, null, null, hash); 8 | // For now progress is not synced back 9 | return null; 10 | } 11 | } -------------------------------------------------------------------------------- /Application/Syncs/GetProgress/GetProgressResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Sync.Application.Syncs.GetProgress; 2 | 3 | public record GetProgressResponse( 4 | decimal Percentage, 5 | int Progress, 6 | string Device, 7 | string DeviceId, 8 | long Timestamp, 9 | string Document 10 | ); -------------------------------------------------------------------------------- /Application/Syncs/UpdateProgress/UpdateProgressCommand.cs: -------------------------------------------------------------------------------- 1 | namespace Sync.Application.Syncs.UpdateProgress; 2 | 3 | public record UpdateProgressCommand( 4 | decimal Percentage, 5 | string Progress, 6 | string Device, 7 | string DeviceId, 8 | string Document 9 | ); -------------------------------------------------------------------------------- /Application/Syncs/UpdateProgress/UpdateProgressHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using Sync.Domain; 3 | using Sync.Domain.Repositories; 4 | 5 | namespace Sync.Application.Syncs.UpdateProgress; 6 | 7 | class UpdateProgressHandler 8 | { 9 | private readonly IUserRepository _userRepository; 10 | 11 | public UpdateProgressHandler(IUserRepository userRepository) 12 | { 13 | _userRepository = userRepository; 14 | } 15 | 16 | public UpdateProgressResponse Handle(ClaimsPrincipal principal, UpdateProgressCommand command) 17 | { 18 | if (command.Percentage == 1) 19 | { 20 | var library = FindLibrary(principal); 21 | library.MarkBookAsRead(command.Document); 22 | } 23 | return new UpdateProgressResponse(command.Document, DateTimeOffset.UtcNow.ToUnixTimeSeconds()); 24 | } 25 | 26 | private Library FindLibrary(ClaimsPrincipal principal) 27 | { 28 | var userDetails = principal.Claims.Single(c => c.Type == ClaimTypes.Name); 29 | var library = _userRepository.Get(userDetails.Issuer, userDetails.Value)?.Library 30 | ?? throw new ApplicationException("Library not found"); 31 | return library; 32 | } 33 | } -------------------------------------------------------------------------------- /Application/Syncs/UpdateProgress/UpdateProgressResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Sync.Application.Syncs.UpdateProgress; 2 | 3 | record UpdateProgressResponse(string Document, long timestamp); -------------------------------------------------------------------------------- /Application/Users/Auth/AuthHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Sync.Application.Users.Auth; 2 | 3 | class AuthHandler 4 | { 5 | public IResult Handle() 6 | { 7 | // Authentication is handled by middleware, 8 | // so if this handler is reached, everything is fine 9 | return Results.Ok(); 10 | } 11 | } -------------------------------------------------------------------------------- /Application/Users/CreateUser/CreateUserCommand.cs: -------------------------------------------------------------------------------- 1 | namespace Sync.Application.Users.CreteUser; 2 | 3 | record CreateUserCommand( 4 | string Username, 5 | string Password 6 | ); -------------------------------------------------------------------------------- /Application/Users/CreateUser/CreateUserHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Sync.Application.Users.CreteUser; 2 | 3 | class CreateUserHandler 4 | { 5 | public IResult Handle(CreateUserCommand command) 6 | { 7 | return Results.BadRequest("No users can be created. Please login to an existing account."); 8 | } 9 | } -------------------------------------------------------------------------------- /Configuration/AuthOperationsFilter.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authorization; 2 | using Microsoft.OpenApi.Models; 3 | using Swashbuckle.AspNetCore.SwaggerGen; 4 | 5 | namespace Sync.Configuration; 6 | 7 | public class AuthOperationsFilter : IOperationFilter 8 | { 9 | public void Apply(OpenApiOperation operation, OperationFilterContext ctx) 10 | { 11 | var requiresAuth = ctx.MethodInfo 12 | .GetCustomAttributes(true) 13 | .OfType() 14 | .Any(); 15 | 16 | if (requiresAuth) 17 | { 18 | operation.Security = new List 19 | { 20 | new OpenApiSecurityRequirement 21 | { 22 | { 23 | new OpenApiSecurityScheme 24 | { 25 | Reference = new OpenApiReference 26 | { 27 | Type = ReferenceType.SecurityScheme, 28 | Id = "user" 29 | } 30 | }, 31 | new List() 32 | }, 33 | { 34 | new OpenApiSecurityScheme 35 | { 36 | Reference = new OpenApiReference 37 | { 38 | Type = ReferenceType.SecurityScheme, 39 | Id = "pass" 40 | } 41 | }, 42 | new List() 43 | } 44 | } 45 | }; 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /Configuration/AuthenticationService.cs: -------------------------------------------------------------------------------- 1 | using Sync.Domain; 2 | using Sync.Domain.Repositories; 3 | using Sync.Infrastructure.Services; 4 | 5 | namespace Sync.Configuration; 6 | 7 | public class AuthenticationService 8 | { 9 | private readonly IUserRepository _userRepository; 10 | private readonly CalibreWebService _calibre; 11 | 12 | public AuthenticationService(IUserRepository userRepository, CalibreWebService calibre) 13 | { 14 | _userRepository = userRepository; 15 | _calibre = calibre; 16 | } 17 | 18 | public async Task AuthenticateAsync(User user) 19 | { 20 | var cachedUser = _userRepository.Get(user.HostUrl, user.Username); 21 | if (cachedUser != null && cachedUser.Password == user.Password) 22 | return true; 23 | 24 | if (await _calibre.LoginAsync(user)) 25 | { 26 | if (cachedUser != null) 27 | { 28 | _userRepository.Update(user); 29 | } 30 | else 31 | { 32 | _userRepository.Add(user); 33 | } 34 | return true; 35 | } 36 | 37 | return false; 38 | } 39 | 40 | 41 | } -------------------------------------------------------------------------------- /Configuration/CustomAuthenticationHandler.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using System.Text.Encodings.Web; 3 | using Microsoft.AspNetCore.Authentication; 4 | using Microsoft.Extensions.Options; 5 | using Sync.Domain; 6 | 7 | namespace Sync.Configuration; 8 | 9 | public class CustomAuthenticationHandler : AuthenticationHandler 10 | { 11 | private readonly AuthenticationService _authenticationService; 12 | 13 | public CustomAuthenticationHandler( 14 | IOptionsMonitor options, 15 | ILoggerFactory logger, 16 | UrlEncoder encoder, 17 | ISystemClock clock, 18 | AuthenticationService authenticationService 19 | ) : base(options, logger, encoder, clock) 20 | { 21 | _authenticationService = authenticationService; 22 | } 23 | 24 | protected async override Task HandleAuthenticateAsync() 25 | { 26 | var authUserHeader = Request.Headers["x-auth-user"].ToString(); 27 | if (!string.IsNullOrWhiteSpace(authUserHeader)) 28 | { 29 | if (!authUserHeader.Contains('@')) 30 | { 31 | Response.StatusCode = 401; 32 | return AuthenticateResult.Fail("Please append \"@https://host\" to your username to define the server url."); 33 | } 34 | 35 | var parts = authUserHeader.Split('@', StringSplitOptions.TrimEntries); 36 | var username = string.Join('@', parts[0..(parts.Length - 1)]); 37 | var hostUrl = parts.Last(); 38 | 39 | if (!username.Contains(':')) 40 | { 41 | Response.StatusCode = 401; 42 | return AuthenticateResult.Fail("Please append \":@https://host\" to your username to define your password."); 43 | } 44 | 45 | parts = username.Split(':', StringSplitOptions.TrimEntries); 46 | username = parts[0]; 47 | var password = string.Join(':', parts[1..]); 48 | 49 | var user = new User(hostUrl, username, password); 50 | 51 | if (await _authenticationService.AuthenticateAsync(user)) 52 | { 53 | var claims = new[] { new Claim(ClaimTypes.Name, user.Username, null, user.HostUrl) }; 54 | var identity = new ClaimsIdentity(claims, Scheme.Name); 55 | var claimsPrincipal = new ClaimsPrincipal(identity); 56 | return AuthenticateResult.Success(new AuthenticationTicket(claimsPrincipal, Scheme.Name)); 57 | } 58 | 59 | Response.StatusCode = 401; 60 | return AuthenticateResult.Fail("Invalid username and/or password"); 61 | } 62 | else 63 | { 64 | Response.StatusCode = 401; 65 | return AuthenticateResult.Fail("Not authenticated"); 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /Configuration/SnakeCaseNamingPolicy.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | 3 | namespace Sync.Configuration; 4 | 5 | public class SnakeCaseNamingPolicy : JsonNamingPolicy 6 | { 7 | public static SnakeCaseNamingPolicy Instance { get; } = new SnakeCaseNamingPolicy(); 8 | 9 | public override string ConvertName(string name) 10 | { 11 | return name.ToSnakeCase(); 12 | } 13 | } -------------------------------------------------------------------------------- /Configuration/SnakeCaseSchemaFilter.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.OpenApi.Models; 2 | using Swashbuckle.AspNetCore.SwaggerGen; 3 | 4 | namespace Sync.Configuration; 5 | 6 | public class SnakeCaseSchemaFilter : ISchemaFilter 7 | { 8 | public void Apply(OpenApiSchema schema, SchemaFilterContext context) 9 | { 10 | if (schema.Properties == null) return; 11 | if (schema.Properties.Count == 0) return; 12 | 13 | var keys = schema.Properties.Keys; 14 | var newProperties = new Dictionary(); 15 | foreach (var key in keys) 16 | { 17 | newProperties[key.ToSnakeCase()] = schema.Properties[key]; 18 | } 19 | 20 | schema.Properties = newProperties; 21 | } 22 | } -------------------------------------------------------------------------------- /Configuration/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Sync.Configuration; 2 | 3 | public static class StringExtensions 4 | { 5 | public static string ToSnakeCase(this string str) 6 | { 7 | return string.Concat(str.Select((character, index) => 8 | index > 0 && char.IsUpper(character) 9 | ? "_" + character 10 | : character.ToString())) 11 | .ToLower(); 12 | } 13 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build 2 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build 3 | WORKDIR /app 4 | COPY Sync.csproj ./ 5 | RUN dotnet restore "Sync.csproj" --runtime linux-musl-x64 6 | COPY . . 7 | RUN dotnet publish -c Release -o out \ 8 | --no-restore \ 9 | --runtime linux-musl-x64 \ 10 | --self-contained true \ 11 | /p:PublishTrimmed=true \ 12 | /p:PublishSingleFile=true 13 | 14 | # Run 15 | FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine AS final 16 | 17 | RUN adduser --disabled-password \ 18 | --home /app \ 19 | --gecos '' dotnetuser && chown -R dotnetuser /app 20 | 21 | USER dotnetuser 22 | WORKDIR /app 23 | 24 | EXPOSE 5000 25 | 26 | COPY --from=build /app/out . 27 | ENV ASPNETCORE_URLS=http://*:5000 28 | CMD ./Sync -------------------------------------------------------------------------------- /Domain/Book.cs: -------------------------------------------------------------------------------- 1 | 2 | namespace Sync.Domain; 3 | 4 | public class Book 5 | { 6 | public IList Hashes { get; init; } 7 | 8 | public int? CalibreId { get; private set; } 9 | 10 | public bool Read { get; private set; } 11 | 12 | private bool _needsSync; 13 | 14 | public bool NeedsSync => _needsSync && CalibreId != null; 15 | 16 | public Book(string hash, int? calibreId = null, bool read = false) : this(new[] { hash }, calibreId, read) 17 | { 18 | 19 | } 20 | 21 | public Book(IEnumerable hashes, int? calibreId = null, bool read = false) 22 | { 23 | Hashes = hashes.ToList(); 24 | CalibreId = calibreId; 25 | Read = read; 26 | } 27 | 28 | public void MarkAsRead() 29 | { 30 | Read = true; 31 | _needsSync = true; 32 | } 33 | 34 | public void UpdateFromRemote(Book book) 35 | { 36 | CalibreId = book.CalibreId; 37 | 38 | foreach (var hash in book.Hashes.Except(Hashes)) 39 | Hashes.Add(hash); 40 | 41 | if (!book.Read && Read) 42 | _needsSync = true; 43 | } 44 | 45 | public bool MatchHash(string hash) 46 | { 47 | return Hashes.Contains(hash, StringComparer.InvariantCultureIgnoreCase); 48 | } 49 | 50 | internal void Synced() 51 | { 52 | _needsSync = false; 53 | } 54 | } -------------------------------------------------------------------------------- /Domain/Library.cs: -------------------------------------------------------------------------------- 1 | 2 | 3 | namespace Sync.Domain; 4 | 5 | public class Library 6 | { 7 | public DateTimeOffset? LastSync { get; private set; } 8 | private IList _books = new List(); 9 | 10 | public void Add(Book book) 11 | { 12 | _books.Add(book); 13 | } 14 | 15 | public Book? GetBookByHash(string hash) 16 | { 17 | return _books.FirstOrDefault(b => b.MatchHash(hash)); 18 | } 19 | 20 | public Book? GetBookByHash(IEnumerable hashes) 21 | { 22 | return _books.FirstOrDefault(b => hashes.Any(b.MatchHash)); 23 | } 24 | 25 | public void MarkBookAsRead(string document) 26 | { 27 | var book = GetBookByHash(document); 28 | if (book == null) 29 | Add(new Book(document, null, true)); 30 | else 31 | book.MarkAsRead(); 32 | } 33 | 34 | public bool NeedsSync() 35 | { 36 | return (NotUpdatedLastHour && HasUnknownBooks) || HasUnsyncedBooks; 37 | } 38 | 39 | private bool NotUpdatedLastHour => LastSync == null || LastSync < DateTimeOffset.UtcNow.Add(TimeSpan.FromHours(-1)); 40 | private bool HasUnknownBooks => _books.Any(b => b.CalibreId == null); 41 | private bool HasUnsyncedBooks => _books.Any(b => b.NeedsSync); 42 | 43 | public IEnumerable GetBookToSync() 44 | { 45 | return _books.Where(b => b.NeedsSync).ToList(); 46 | } 47 | 48 | public void MarkAsSynced() 49 | { 50 | LastSync = DateTimeOffset.UtcNow; 51 | } 52 | } -------------------------------------------------------------------------------- /Domain/Repositories/IUserRepository.cs: -------------------------------------------------------------------------------- 1 | namespace Sync.Domain.Repositories; 2 | 3 | public interface IUserRepository 4 | { 5 | void Add(User user); 6 | User? Get(string hostUrl, string user); 7 | void Update(User user); 8 | IEnumerable GetAll(); 9 | } -------------------------------------------------------------------------------- /Domain/Services/HashGenerator.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography; 2 | using System.Text.RegularExpressions; 3 | 4 | namespace Sync.Domain.Services; 5 | 6 | public class HashGenerator 7 | { 8 | public static IEnumerable CreateHash(string title, string author, IEnumerable extensions) 9 | { 10 | var fileName = GetValidFileName(author + " - " + title); 11 | return extensions.Select(ext => CreateMD5(fileName + "." + ext)).ToList(); 12 | } 13 | 14 | private static string GetValidFileName(string input) 15 | { 16 | if (input.Last() == '.') 17 | input = input[..^1] + '_'; 18 | input = input.Replace('/', '_').Replace(':', '_').Trim('\0'); 19 | 20 | // todo: check config_unicode_filename feature in Calibre-web configuration and unidecode if needed 21 | // config_unicode_filename is off by default in Calibre-web, 22 | // so non unicode won't be replaced. 23 | 24 | input = Regex.Replace(input, @"[^\u0000-\u007F]+", string.Empty).Trim(); 25 | 26 | if (string.IsNullOrEmpty(input)) 27 | throw new ArgumentOutOfRangeException(nameof(input), "Input contains no valid characters"); 28 | 29 | return input; 30 | } 31 | 32 | private static string CreateMD5(string input) 33 | { 34 | var inputBytes = System.Text.Encoding.ASCII.GetBytes(input); 35 | var hashBytes = MD5.HashData(inputBytes); 36 | 37 | return Convert.ToHexString(hashBytes); 38 | } 39 | } -------------------------------------------------------------------------------- /Domain/User.cs: -------------------------------------------------------------------------------- 1 | namespace Sync.Domain; 2 | 3 | public record User(string HostUrl, string Username, string Password) 4 | { 5 | public Library Library { get; } = new Library(); 6 | } -------------------------------------------------------------------------------- /Infrastructure/Repositories/UserRepository.cs: -------------------------------------------------------------------------------- 1 | using Sync.Domain; 2 | using Sync.Domain.Repositories; 3 | 4 | namespace Sync.Infrastructure.Repositories; 5 | 6 | public class UserRepository : IUserRepository 7 | { 8 | private IList _users = new List(); 9 | 10 | public void Add(User user) 11 | { 12 | lock (_users) 13 | { 14 | if (Get(user.HostUrl, user.Username) != null) 15 | throw new ArgumentOutOfRangeException(nameof(user), "User already exists"); 16 | _users.Add(user); 17 | } 18 | } 19 | 20 | public User? Get(string hostUrl, string username) 21 | { 22 | return _users.SingleOrDefault(u => u.HostUrl == hostUrl && u.Username == username); 23 | } 24 | 25 | public void Update(User user) 26 | { 27 | lock (_users) 28 | { 29 | var existing = Get(user.HostUrl, user.Username); 30 | if (existing == null) 31 | throw new ArgumentOutOfRangeException(nameof(user), "Could not find user"); 32 | _users.Remove(existing); 33 | _users.Add(user); 34 | } 35 | } 36 | 37 | public IEnumerable GetAll() 38 | { 39 | return _users; 40 | } 41 | } -------------------------------------------------------------------------------- /Infrastructure/Services/CalibreWebService.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | using Sync.Domain; 3 | using Sync.Domain.Services; 4 | using Sync.Infrastructure.Services.Models; 5 | 6 | namespace Sync.Infrastructure.Services; 7 | 8 | public class CalibreWebService 9 | { 10 | private readonly HttpClient _httpClient; 11 | 12 | public CalibreWebService(HttpClient httpClient) 13 | { 14 | _httpClient = httpClient; 15 | } 16 | 17 | public async Task LoginAsync(User user, CancellationToken? cancellationToken = null) 18 | { 19 | var baseUri = new Uri(user.HostUrl); 20 | var csrfToken = await GetCsrfToken(baseUri); 21 | 22 | var content = new FormUrlEncodedContent(new KeyValuePair[] 23 | { 24 | new ("csrf_token", csrfToken), 25 | new ("username", user.Username), 26 | new ("password", user.Password) 27 | }); 28 | 29 | var request = new HttpRequestMessage(HttpMethod.Post, new Uri(baseUri, "/login")) 30 | { 31 | Content = content, 32 | }; 33 | request.Headers.Add("origin", baseUri.ToString()); 34 | request.Headers.Add("referer", new Uri(baseUri, "/login").ToString()); 35 | 36 | var loginResponse = await _httpClient.SendAsync(request, cancellationToken ?? CancellationToken.None); 37 | var loginResultContent = await loginResponse.Content.ReadAsStringAsync(cancellationToken ?? CancellationToken.None); 38 | return !loginResultContent.Contains("Wrong Username or Password"); 39 | } 40 | 41 | private async Task GetCsrfToken(Uri baseUri) 42 | { 43 | var loginPageContent = await _httpClient.GetStringAsync(new Uri(baseUri, "/login")); 44 | var matches = Regex.Match(loginPageContent, "name=\"csrf_token\" value=\"(.*)\">"); 45 | var csrfToken = matches.Groups[1].Value; 46 | return csrfToken; 47 | } 48 | 49 | public async Task> GetBooksAsync(User user, CancellationToken? cancellationToken = null) 50 | { 51 | if (!await LoginAsync(user, cancellationToken)) 52 | throw new ApplicationException("Credentials are not valid anymore"); 53 | 54 | var baseUri = new Uri(user.HostUrl); 55 | var result = await _httpClient.GetStringAsync(new Uri(baseUri, "/ajax/listbooks?limit=100000000000"), cancellationToken ?? CancellationToken.None); 56 | 57 | var results = await _httpClient.GetFromJsonAsync(new Uri(baseUri, "/ajax/listbooks?limit=100000000000"), cancellationToken ?? CancellationToken.None); 58 | 59 | return results?.Rows.Select(r => new Book(CreateHashes(r), r.Id, r.ReadStatus)).ToList() ?? new List(); 60 | } 61 | 62 | private static readonly string[] _extensions = new[] { "pdf", "epub", "mobi", "azw3", "docx", "rtf", "fb2", "lit", "lrf", 63 | "txt", "htmlz", "rtf", "odt", "cbz", "cbr", "prc" }; 64 | private IEnumerable CreateHashes(CalibreFeedRow rawBook) 65 | { 66 | var result = new List(); 67 | var authors = rawBook.Authors.Split(new[] { ',', '&' }, StringSplitOptions.TrimEntries); 68 | foreach (var author in authors) 69 | { 70 | result.AddRange(HashGenerator.CreateHash(rawBook.Title, author, _extensions)); 71 | } 72 | return result; 73 | } 74 | 75 | public async Task MarkBookAsReadSync(User user, int calibreId, CancellationToken? cancellationToken = null) 76 | { 77 | if (!await LoginAsync(user, cancellationToken)) 78 | throw new ApplicationException("Credentials are not valid anymore"); 79 | 80 | var baseUri = new Uri(user.HostUrl); 81 | var csrfToken = await GetCsrfToken(baseUri); 82 | 83 | var content = new FormUrlEncodedContent(new KeyValuePair[] 84 | { 85 | new ("pk", calibreId.ToString()), 86 | new ("value", "True") 87 | }); 88 | 89 | var request = new HttpRequestMessage(HttpMethod.Post, new Uri(baseUri, "/ajax/editbooks/read_status")) 90 | { 91 | Content = content, 92 | }; 93 | request.Headers.Add("X-Csrftoken", csrfToken); 94 | request.Headers.Add("origin", baseUri.ToString()); 95 | request.Headers.Add("referer", new Uri(baseUri, "/ajax/editbooks/read_status").ToString()); 96 | 97 | var response = await _httpClient.SendAsync(request, cancellationToken ?? CancellationToken.None); 98 | if (!response.IsSuccessStatusCode) 99 | throw new ApplicationException("Could not mark book as read"); 100 | } 101 | } -------------------------------------------------------------------------------- /Infrastructure/Services/Models/CalibreFeed.cs: -------------------------------------------------------------------------------- 1 | namespace Sync.Infrastructure.Services.Models; 2 | 3 | public record CalibreFeed(CalibreFeedRow[] Rows); -------------------------------------------------------------------------------- /Infrastructure/Services/Models/CalibreFeedRow.cs: -------------------------------------------------------------------------------- 1 | namespace Sync.Infrastructure.Services.Models; 2 | 3 | public record CalibreFeedRow(int Id, string Title, string Authors, bool ReadStatus); -------------------------------------------------------------------------------- /Infrastructure/Setup.cs: -------------------------------------------------------------------------------- 1 | using Sync.Domain.Repositories; 2 | using Sync.Infrastructure.Repositories; 3 | using Sync.Infrastructure.Services; 4 | 5 | namespace Sync.Infrastructure; 6 | 7 | public static class Setup 8 | { 9 | public static void AddServices(IServiceCollection services) 10 | { 11 | services.AddHttpClient(); 12 | services.AddSingleton(); 13 | services.AddSingleton(); 14 | } 15 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Vincent Bitter 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Authentication; 2 | using Microsoft.OpenApi.Models; 3 | using Sync.Configuration; 4 | 5 | var builder = WebApplication.CreateBuilder(args); 6 | 7 | builder.Services.AddEndpointsApiExplorer(); 8 | builder.Services.AddSwaggerGen(options => 9 | { 10 | options.AddSecurityDefinition("user", new OpenApiSecurityScheme 11 | { 12 | Description = "Username:Password@url", 13 | In = ParameterLocation.Header, 14 | Name = "x-auth-user", 15 | Type = SecuritySchemeType.ApiKey 16 | }); 17 | options.OperationFilter(); 18 | options.SchemaFilter(); 19 | }); 20 | 21 | builder.Services.ConfigureHttpJsonOptions(options => 22 | { 23 | options.SerializerOptions.PropertyNamingPolicy = SnakeCaseNamingPolicy.Instance; 24 | }); 25 | 26 | builder.Services.AddAuthentication("custom") 27 | .AddScheme("custom", null); 28 | builder.Services.AddAuthorization(); 29 | builder.Services.AddSingleton(); 30 | 31 | Sync.Infrastructure.Setup.AddServices(builder.Services); 32 | Sync.Application.Setup.AddServices(builder.Services); 33 | 34 | var app = builder.Build(); 35 | 36 | app.UseAuthentication(); 37 | app.UseAuthorization(); 38 | 39 | app.UseSwagger(); 40 | app.UseSwaggerUI(); 41 | 42 | Sync.Application.Setup.AddRoutes(app); 43 | 44 | app.Run(); 45 | -------------------------------------------------------------------------------- /Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:26341", 7 | "sslPort": 44311 8 | } 9 | }, 10 | "profiles": { 11 | "http": { 12 | "commandName": "Project", 13 | "dotnetRunMessages": true, 14 | "launchBrowser": true, 15 | "applicationUrl": "http://localhost:5157", 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | } 19 | }, 20 | "https": { 21 | "commandName": "Project", 22 | "dotnetRunMessages": true, 23 | "launchBrowser": true, 24 | "applicationUrl": "https://localhost:7023;http://localhost:5157", 25 | "environmentVariables": { 26 | "ASPNETCORE_ENVIRONMENT": "Development" 27 | } 28 | }, 29 | "IIS Express": { 30 | "commandName": "IISExpress", 31 | "launchBrowser": true, 32 | "environmentVariables": { 33 | "ASPNETCORE_ENVIRONMENT": "Development" 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KOReader Calibre-Web Sync 2 | 3 | Synchronize reader data from KOReader to Calibre-Web. 4 | 5 | ## Setup 6 | 7 | The application is distributed as docker image: 8 | 9 | ``` 10 | docker run --restart=always -d -p 5000:5000 \ 11 | --name=koreader \ 12 | vincentbitter/koreader-calibre-web-sync:latest 13 | ``` 14 | 15 | **Notes:** 16 | 17 | - This application was only tested with default authentication, so not with LDAP, OAuth or Reverse Proxy Authentication. As long as the normal login form is used, it should work, but especially 2FA is not supported. 18 | - Make sure "Convert non-English characters in title and author while saving to disk" is disabled in the Feature Configuration section of the Basic Configuration page of Calibre-Web. Otherwise, different hashes might be calculated for books making it impossible to match. 19 | - Use "Filename" as Document matching method in KOReader. 20 | 21 | ## How it works 22 | 23 | Within KOReader, you can specify a custom sync server to synchronize reading progress. KOReader Calibre-Web Sync functions as a KOReader reading progress server. If the progress is 100% (last page is visited), it will mark the book as "Read" in Calibre-Web by logging in as a user via the web application. 24 | 25 | ## How to use 26 | 27 | After the application is setup, make sure you have the correct URL to the docker container, that can be accessed from the e-reader. This can be a local address on the network (e.g. http://10.0.0.100:5000) or a public URL (https://stats.mykoreader.com). 28 | 29 | Within KOReader, open a book and open the Tools menu and go to Progress Sync. Use the URL of your KOReader Calibre-Web Sync installation as Custom sync server (e.g. https://stats.mykoreader.com). Now Login is a bit different than normal, because you need to provide the username, password and URL of Calibre-Web. Therefore click "Register / Login" in the menu and use the following format as username: `:@`. So if your Calibre-Web is hosted on https://my.calibre-web.com where you login with username `vincent` and password `secret`, then you need to use `vincent:secret@https://my.calibre-web.com` as your username. The password is ignored, so you can fill in a random value. Click on the Login button to validate your credentials. 30 | 31 | If you change your Calibre-Web credentials, make sure you Logout and Login again to update the credentials. 32 | -------------------------------------------------------------------------------- /Sync.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | Sync 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | --------------------------------------------------------------------------------