diff --git a/komga-webui/src/components/dialogs/LibraryEditDialog.vue b/komga-webui/src/components/dialogs/LibraryEditDialog.vue index d9492279eda..158eb5de931 100644 --- a/komga-webui/src/components/dialogs/LibraryEditDialog.vue +++ b/komga-webui/src/components/dialogs/LibraryEditDialog.vue @@ -215,6 +215,22 @@ + + + + @@ -150,6 +151,11 @@ class TaskHandler( bookLifecycle.hashAndPersist(book) } ?: logger.warn { "Cannot execute task $task: Book does not exist" } + is Task.HashBookKoreader -> + bookRepository.findByIdOrNull(task.bookId)?.let { book -> + bookLifecycle.hashKoreaderAndPersist(book) + } ?: logger.warn { "Cannot execute task $task: Book does not exist" } + is Task.HashBookPages -> bookRepository.findByIdOrNull(task.bookId)?.let { book -> bookLifecycle.hashPagesAndPersist(book) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Book.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Book.kt index d74f04c2290..b27ff81f8c1 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/Book.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Book.kt @@ -12,6 +12,7 @@ data class Book( val fileLastModified: LocalDateTime, val fileSize: Long = 0, val fileHash: String = "", + val fileHashKoreader: String = "", val number: Int = 0, val id: String = TsidCreator.getTsid256().toString(), val seriesId: String = "", diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt index a274badeb23..8ed2fd71499 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Library.kt @@ -32,6 +32,7 @@ data class Library( val seriesCover: SeriesCover = SeriesCover.FIRST, val hashFiles: Boolean = true, val hashPages: Boolean = false, + val hashKoreader: Boolean = false, val analyzeDimensions: Boolean = true, val oneshotsDirectory: String? = null, val unavailableDate: LocalDateTime? = null, diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt index 7045b66f8ca..fa73e80ce2a 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/BookRepository.kt @@ -37,6 +37,10 @@ interface BookRepository { fun findAllByLibraryIdAndWithEmptyHash(libraryId: String): Collection + fun findAllByLibraryIdAndWithEmptyHashKoreader(libraryId: String): Collection + + fun findAllByHashKoreader(hashKoreader: String): Collection + fun findAllByLibraryIdAndMediaTypes( libraryId: String, mediaTypes: Collection, diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt index 052d40bf840..5d7fc605d43 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookLifecycle.kt @@ -31,6 +31,7 @@ import org.gotson.komga.domain.persistence.ReadProgressRepository import org.gotson.komga.domain.persistence.ThumbnailBookRepository import org.gotson.komga.infrastructure.configuration.KomgaSettingsProvider import org.gotson.komga.infrastructure.hash.Hasher +import org.gotson.komga.infrastructure.hash.KoreaderHasher import org.gotson.komga.infrastructure.image.ImageConverter import org.gotson.komga.infrastructure.image.ImageType import org.gotson.komga.language.toCurrentTimeZone @@ -66,6 +67,7 @@ class BookLifecycle( private val eventPublisher: ApplicationEventPublisher, private val transactionTemplate: TransactionTemplate, private val hasher: Hasher, + private val hasherKoreader: KoreaderHasher, private val historicalEventRepository: HistoricalEventRepository, private val komgaSettingsProvider: KomgaSettingsProvider, @Qualifier("pdfImageType") @@ -113,6 +115,19 @@ class BookLifecycle( } } + fun hashKoreaderAndPersist(book: Book) { + if (!libraryRepository.findById(book.libraryId).hashKoreader) + return logger.info { "File hashing for Koreader is disabled for the library, it may have changed since the task was submitted, skipping" } + + logger.info { "Hash Koreader and persist book: $book" } + if (book.fileHashKoreader.isBlank()) { + val hash = hasherKoreader.computeHash(book.path) + bookRepository.update(book.copy(fileHashKoreader = hash)) + } else { + logger.info { "Book already has a Koreader hash, skipping" } + } + } + fun hashPagesAndPersist(book: Book) { if (!libraryRepository.findById(book.libraryId).hashPages) return logger.info { "Page hashing is disabled for the library, it may have changed since the task was submitted, skipping" } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/hash/KoreaderHasher.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/hash/KoreaderHasher.kt new file mode 100644 index 00000000000..158678ce096 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/hash/KoreaderHasher.kt @@ -0,0 +1,39 @@ +package org.gotson.komga.infrastructure.hash + +import com.appmattus.crypto.Algorithm +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import java.io.RandomAccessFile +import java.nio.file.Path + +private val logger = KotlinLogging.logger {} + +@Component +class KoreaderHasher { + fun computeHash(path: Path): String { + logger.debug { "Koreader hashing: $path" } + + return partialMd5(path) + } + + /** + * From https://github.com/koreader/koreader/blob/5bd3f3b42c95fd143d98f8fc9695d486fd92b7c8/frontend/util.lua#L1093-L1119 + */ + @OptIn(ExperimentalStdlibApi::class) + private fun partialMd5(path: Path): String { + val step = 1024L + val size = 1024 + val digest = Algorithm.MD5.createDigest() + + val file = RandomAccessFile(path.toFile(), "r") + + val buffer = ByteArray(size) + (-1..10).forEach { + file.seek(step shl (2 * it)) + val s = file.read(buffer) + if (s > 0) digest.update(buffer) + } + + return digest.digest().toHexString() + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDao.kt index 488c63dc946..9b0ce070a35 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/BookDao.kt @@ -300,6 +300,21 @@ class BookDao( .fetchInto(b) .map { it.toDomain() } + override fun findAllByLibraryIdAndWithEmptyHashKoreader(libraryId: String): Collection = + dsl + .selectFrom(b) + .where(b.LIBRARY_ID.eq(libraryId)) + .and(b.FILE_HASH_KOREADER.eq("")) + .fetchInto(b) + .map { it.toDomain() } + + override fun findAllByHashKoreader(hashKoreader: String): Collection = + dsl + .selectFrom(b) + .where(b.FILE_HASH_KOREADER.eq(hashKoreader)) + .fetchInto(b) + .map { it.toDomain() } + @Transactional override fun insert(book: Book) { insert(listOf(book)) @@ -321,11 +336,12 @@ class BookDao( b.FILE_LAST_MODIFIED, b.FILE_SIZE, b.FILE_HASH, + b.FILE_HASH_KOREADER, b.LIBRARY_ID, b.SERIES_ID, b.DELETED_DATE, b.ONESHOT, - ).values(null as String?, null, null, null, null, null, null, null, null, null, null), + ).values(null as String?, null, null, null, null, null, null, null, null, null, null, null), ).also { step -> chunk.forEach { step.bind( @@ -336,6 +352,7 @@ class BookDao( it.fileLastModified, it.fileSize, it.fileHash, + it.fileHashKoreader, it.libraryId, it.seriesId, it.deletedDate, @@ -366,6 +383,7 @@ class BookDao( .set(b.FILE_LAST_MODIFIED, book.fileLastModified) .set(b.FILE_SIZE, book.fileSize) .set(b.FILE_HASH, book.fileHash) + .set(b.FILE_HASH_KOREADER, book.fileHashKoreader) .set(b.LIBRARY_ID, book.libraryId) .set(b.SERIES_ID, book.seriesId) .set(b.DELETED_DATE, book.deletedDate) @@ -413,6 +431,7 @@ class BookDao( fileLastModified = fileLastModified, fileSize = fileSize, fileHash = fileHash, + fileHashKoreader = fileHashKoreader, id = id, libraryId = libraryId, seriesId = seriesId, diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/LibraryDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/LibraryDao.kt index 98552c181e7..e8318327646 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/LibraryDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/main/LibraryDao.kt @@ -102,6 +102,7 @@ class LibraryDao( .set(l.SERIES_COVER, library.seriesCover.toString()) .set(l.HASH_FILES, library.hashFiles) .set(l.HASH_PAGES, library.hashPages) + .set(l.HASH_KOREADER, library.hashKoreader) .set(l.ANALYZE_DIMENSIONS, library.analyzeDimensions) .set(l.ONESHOTS_DIRECTORY, library.oneshotsDirectory) .set(l.UNAVAILABLE_DATE, library.unavailableDate) @@ -138,6 +139,7 @@ class LibraryDao( .set(l.SERIES_COVER, library.seriesCover.toString()) .set(l.HASH_FILES, library.hashFiles) .set(l.HASH_PAGES, library.hashPages) + .set(l.HASH_KOREADER, library.hashKoreader) .set(l.ANALYZE_DIMENSIONS, library.analyzeDimensions) .set(l.ONESHOTS_DIRECTORY, library.oneshotsDirectory) .set(l.UNAVAILABLE_DATE, library.unavailableDate) @@ -200,6 +202,7 @@ class LibraryDao( seriesCover = Library.SeriesCover.valueOf(seriesCover), hashFiles = hashFiles, hashPages = hashPages, + hashKoreader = hashKoreader, analyzeDimensions = analyzeDimensions, oneshotsDirectory = oneshotsDirectory, unavailableDate = unavailableDate, diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt index 23ed469080e..fb11c1da5a4 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/security/SecurityConfiguration.kt @@ -92,6 +92,8 @@ class SecurityConfiguration( "/api/v1/books/{bookId}/resource/**", // OPDS authentication document "/opds/v2/auth", + // KOReader user creation + "/koreader/users/create", ).permitAll() // all other endpoints are restricted to authenticated users @@ -210,7 +212,7 @@ class SecurityConfiguration( httpBasic { disable() } logout { disable() } - securityMatcher("/kosync/**") + securityMatcher("/koreader/**") authorizeHttpRequests { authorize(anyRequest, hasRole(UserRoles.KOREADER_SYNC.name)) } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kosync/KoreaderSyncController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kosync/KoreaderSyncController.kt index 2228b8cd6f9..0dc2a8e11da 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kosync/KoreaderSyncController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kosync/KoreaderSyncController.kt @@ -1,10 +1,20 @@ package org.gotson.komga.interfaces.api.kosync -import com.fasterxml.jackson.databind.JsonNode import io.github.oshai.kotlinlogging.KotlinLogging +import org.gotson.komga.domain.model.MediaProfile +import org.gotson.komga.domain.model.R2Device +import org.gotson.komga.domain.model.R2Locator +import org.gotson.komga.domain.model.R2Progression +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.MediaRepository +import org.gotson.komga.domain.persistence.ReadProgressRepository +import org.gotson.komga.domain.service.BookLifecycle +import org.gotson.komga.infrastructure.security.KomgaPrincipal +import org.gotson.komga.interfaces.api.kosync.dto.DocumentProgressDto import org.gotson.komga.interfaces.api.kosync.dto.UserAuthenticationDto import org.springframework.http.HttpStatus -import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -13,31 +23,97 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import org.springframework.web.server.ResponseStatusException +import java.time.ZonedDateTime private val logger = KotlinLogging.logger {} @RestController -@RequestMapping("/kosync", produces = [MediaType.APPLICATION_JSON_VALUE]) -class KoreaderSyncController { +@RequestMapping("/koreader", produces = ["application/vnd.koreader.v1+json"]) +class KoreaderSyncController( + private val bookRepository: BookRepository, + private val mediaRepository: MediaRepository, + private val readProgressRepository: ReadProgressRepository, + private val bookLifecycle: BookLifecycle, +) { @PostMapping("users/create") - fun registerUser(): Unit = throw ResponseStatusException(HttpStatus.FORBIDDEN) + fun registerUser(): ResponseEntity = throw ResponseStatusException(HttpStatus.FORBIDDEN, "User creation is disabled") @GetMapping("users/auth") fun authorize() = UserAuthenticationDto() @GetMapping("syncs/progress/{bookHash}") fun getProgress( + @AuthenticationPrincipal principal: KomgaPrincipal, @PathVariable bookHash: String, - ) { - logger.debug { "Received progress request for hash: $bookHash" } - throw ResponseStatusException(HttpStatus.NOT_FOUND) + ): DocumentProgressDto { + val books = bookRepository.findAllByHashKoreader(bookHash) + if (books.isEmpty()) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book not found") + if (books.size > 1) throw ResponseStatusException(HttpStatus.CONFLICT, "More than 1 book found with the same hash") + + val book = books.first() + + val progress = + readProgressRepository.findByBookIdAndUserIdOrNull(book.id, principal.user.id) + ?: throw ResponseStatusException(HttpStatus.OK, "No progress found for this book") + + val progressPercentage = + progress + .locator + ?.locations + ?.totalProgression + ?: (progress.page.toFloat() / mediaRepository.findById(book.id).pageCount.toFloat()) + + return DocumentProgressDto( + document = bookHash, + percentage = progressPercentage, + // TODO: handle EPUB + progress = progress.page.toString(), + device = progress.deviceName, + deviceId = progress.deviceId, + ) } @PutMapping("syncs/progress") fun updateProgress( - @RequestBody body: JsonNode, + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestBody koreaderProgress: DocumentProgressDto, ) { - logger.debug { "Received progress update: $body" } - throw ResponseStatusException(HttpStatus.NOT_FOUND) + logger.debug { "Received progress update: $koreaderProgress" } + val books = bookRepository.findAllByHashKoreader(koreaderProgress.document) + if (books.isEmpty()) throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book not found") + if (books.size > 1) throw ResponseStatusException(HttpStatus.CONFLICT, "More than 1 book found with the same hash") + + val book = books.first() + val media = mediaRepository.findById(book.id) + + // convert the Kobo update request to an R2Progression + val locator = + when (media.profile) { + MediaProfile.DIVINA, MediaProfile.PDF -> + R2Locator( + href = "", + type = "", + locations = + R2Locator.Location( + position = koreaderProgress.progress.toInt(), + totalProgression = koreaderProgress.percentage, + ), + ) + MediaProfile.EPUB -> TODO() + null -> TODO() + } + + val r2Progression = + R2Progression( + device = + R2Device( + id = koreaderProgress.deviceId, + name = koreaderProgress.device, + ), + modified = ZonedDateTime.now(), + locator = locator, + ) + + bookLifecycle.markProgression(book, principal.user, r2Progression) } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kosync/dto/DocumentProgressDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kosync/dto/DocumentProgressDto.kt new file mode 100644 index 00000000000..44961b6b4aa --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/kosync/dto/DocumentProgressDto.kt @@ -0,0 +1,27 @@ +package org.gotson.komga.interfaces.api.kosync.dto + +import com.fasterxml.jackson.databind.PropertyNamingStrategies +import com.fasterxml.jackson.databind.annotation.JsonNaming + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy::class) +data class DocumentProgressDto( + /** + * The document hash, computed using the KOReader partial MD5 algorithm. + */ + val document: String, + /** + * Total progress percentage in the document, between 0 and 1. + */ + val percentage: Float, + /** + * Current progress. + * + * For PDF and CBZ, contains the current page number (starting from 1). + * + * For EPUB, contains a position in the form '/body/DocFragment[10]/body/div/p[1]/text().0' + * 'DocFragment[10]' correspond to the index of the resource in the manifest + */ + val progress: String, + val device: String, + val deviceId: String, +) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/LibraryController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/LibraryController.kt index c7451d35480..1b789d7e88c 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/LibraryController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/LibraryController.kt @@ -107,6 +107,7 @@ class LibraryController( seriesCover = library.seriesCover.toDomain(), hashFiles = library.hashFiles, hashPages = library.hashPages, + hashKoreader = library.hashKoreader, analyzeDimensions = library.analyzeDimensions, oneshotsDirectory = library.oneshotsDirectory?.ifBlank { null }, ), @@ -176,6 +177,7 @@ class LibraryController( seriesCover = seriesCover?.toDomain() ?: existing.seriesCover, hashFiles = hashFiles ?: existing.hashFiles, hashPages = hashPages ?: existing.hashPages, + hashKoreader = hashKoreader ?: existing.hashKoreader, analyzeDimensions = analyzeDimensions ?: existing.analyzeDimensions, oneshotsDirectory = if (isSet("oneshotsDirectory")) oneshotsDirectory?.ifBlank { null } else existing.oneshotsDirectory, ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryCreationDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryCreationDto.kt index 3b8a3f08fb9..9309195c44f 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryCreationDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryCreationDto.kt @@ -28,6 +28,7 @@ data class LibraryCreationDto( val seriesCover: SeriesCoverDto = SeriesCoverDto.FIRST, val hashFiles: Boolean = true, val hashPages: Boolean = false, + val hashKoreader: Boolean = false, val analyzeDimensions: Boolean = true, val oneshotsDirectory: String? = null, ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryDto.kt index fee0b6b0fc3..9f45e831df9 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryDto.kt @@ -30,6 +30,7 @@ data class LibraryDto( val seriesCover: SeriesCoverDto, val hashFiles: Boolean, val hashPages: Boolean, + val hashKoreader: Boolean, val analyzeDimensions: Boolean, val oneshotsDirectory: String?, val unavailable: Boolean, @@ -63,6 +64,7 @@ fun Library.toDto(includeRoot: Boolean) = seriesCover = seriesCover.toDto(), hashFiles = hashFiles, hashPages = hashPages, + hashKoreader = hashKoreader, analyzeDimensions = analyzeDimensions, oneshotsDirectory = oneshotsDirectory, unavailable = unavailableDate != null, diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryUpdateDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryUpdateDto.kt index f46481ce156..54e4731e144 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryUpdateDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/api/rest/dto/LibraryUpdateDto.kt @@ -42,6 +42,7 @@ class LibraryUpdateDto { val seriesCover: SeriesCoverDto? = null val hashFiles: Boolean? = null val hashPages: Boolean? = null + val hashKoreader: Boolean? = null val analyzeDimensions: Boolean? = null var oneshotsDirectory: String? by Delegates.observable(null) { prop, _, _ -> diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/kosync/KoreaderSyncControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/kosync/KoreaderSyncControllerTest.kt index 2b68cda57f9..a1e8521f0c0 100644 --- a/komga/src/test/kotlin/org/gotson/komga/interfaces/api/kosync/KoreaderSyncControllerTest.kt +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/api/kosync/KoreaderSyncControllerTest.kt @@ -43,7 +43,7 @@ class KoreaderSyncControllerTest( @Test fun `when creating user then forbidden is thrown`() { mockMvc - .post("/kosync/users/create") + .post("/koreader/users/create") .andExpect { status { isForbidden() } } @@ -52,7 +52,7 @@ class KoreaderSyncControllerTest( @Test fun `given missing X-Auth-User header when authenticating user then forbidden is thrown`() { mockMvc - .get("/kosync/users/auth") + .get("/koreader/users/auth") .andExpect { status { isForbidden() } } @@ -61,7 +61,7 @@ class KoreaderSyncControllerTest( @Test fun `given api key in X-Auth-User header when authenticating user then returns OK`() { mockMvc - .get("/kosync/users/auth") { + .get("/koreader/users/auth") { header("x-auth-user", apiKey) }.andExpect { status { isOk() }