diff --git a/ERRORCODES.md b/ERRORCODES.md index a8906cccb..f1eb98ca5 100644 --- a/ERRORCODES.md +++ b/ERRORCODES.md @@ -1,3 +1,4 @@ + # Error codes Code | Description @@ -18,3 +19,4 @@ ERR_1012 | No match for series ERR_1013 | No unique match for book number within series ERR_1014 | No match for book number within series ERR_1015 | Error while deserializing ComicRack ReadingList +ERR_1016 | Directory not accessible or not a directory diff --git a/komga/build.gradle.kts b/komga/build.gradle.kts index c0c6a98d0..925c7df2f 100644 --- a/komga/build.gradle.kts +++ b/komga/build.gradle.kts @@ -83,6 +83,8 @@ dependencies { implementation("com.github.f4b6a3:tsid-creator:3.0.1") + implementation("com.github.ben-manes.caffeine:caffeine:2.9.0") + // While waiting for https://github.com/xerial/sqlite-jdbc/pull/491 and https://github.com/xerial/sqlite-jdbc/pull/494 // runtimeOnly("org.xerial:sqlite-jdbc:3.32.3.2") // jooqGenerator("org.xerial:sqlite-jdbc:3.32.3.2") @@ -107,7 +109,11 @@ tasks { withType { kotlinOptions { jvmTarget = "1.8" - freeCompilerArgs = listOf("-Xjsr305=strict", "-Xopt-in=kotlin.time.ExperimentalTime") + freeCompilerArgs = listOf( + "-Xjsr305=strict", + "-Xopt-in=kotlin.time.ExperimentalTime", + "-Xopt-in=kotlin.io.path.ExperimentalPathApi" + ) } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/persistence/TransientBookRepository.kt b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/TransientBookRepository.kt new file mode 100644 index 000000000..92a376a8f --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/persistence/TransientBookRepository.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.domain.persistence + +import org.gotson.komga.domain.model.BookWithMedia + +interface TransientBookRepository { + fun findById(transientBookId: String): BookWithMedia? + fun save(transientBook: BookWithMedia) + fun saveAll(transientBooks: Collection) +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt index 26ce70eb6..77f2c2ffb 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/FileSystemScanner.kt @@ -17,6 +17,8 @@ import java.nio.file.attribute.BasicFileAttributes import java.nio.file.attribute.FileTime import java.time.LocalDateTime import java.time.ZoneId +import kotlin.io.path.exists +import kotlin.io.path.readAttributes import kotlin.time.measureTime private val logger = KotlinLogging.logger {} @@ -35,7 +37,7 @@ class FileSystemScanner( logger.info { "Force directory modified time: $forceDirectoryModifiedTime" } if (!(Files.isDirectory(root) && Files.isReadable(root))) - throw DirectoryNotFoundException("Library root is not accessible: $root") + throw DirectoryNotFoundException("Folder is not accessible: $root", "ERR_1016") val scannedSeries = mutableMapOf>() @@ -69,12 +71,7 @@ class FileSystemScanner( supportedExtensions.contains(FilenameUtils.getExtension(file.fileName.toString()).toLowerCase()) && !file.fileName.toString().startsWith(".") ) { - val book = Book( - name = FilenameUtils.getBaseName(file.fileName.toString()), - url = file.toUri().toURL(), - fileLastModified = attrs.getUpdatedTime(), - fileSize = attrs.size() - ) + val book = pathToBook(file, attrs) file.parent.let { key -> if (pathToBooks.containsKey(key)) pathToBooks[key]!!.add(book) else pathToBooks[key] = mutableListOf(book) @@ -118,6 +115,20 @@ class FileSystemScanner( return scannedSeries } + + fun scanFile(path: Path): Book? { + if (!path.exists()) return null + + return pathToBook(path, path.readAttributes()) + } + + private fun pathToBook(path: Path, attrs: BasicFileAttributes): Book = + Book( + name = FilenameUtils.getBaseName(path.fileName.toString()), + url = path.toUri().toURL(), + fileLastModified = attrs.getUpdatedTime(), + fileSize = attrs.size() + ) } fun BasicFileAttributes.getUpdatedTime(): LocalDateTime = diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/TransientBookLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/TransientBookLifecycle.kt new file mode 100644 index 000000000..349a7fe7a --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/TransientBookLifecycle.kt @@ -0,0 +1,45 @@ +package org.gotson.komga.domain.service + +import org.gotson.komga.domain.model.BookPageContent +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.MediaNotReadyException +import org.gotson.komga.domain.persistence.TransientBookRepository +import org.springframework.stereotype.Service +import java.nio.file.Paths + +@Service +class TransientBookLifecycle( + private val transientBookRepository: TransientBookRepository, + private val bookAnalyzer: BookAnalyzer, + private val fileSystemScanner: FileSystemScanner, +) { + + fun scanAndPersist(filePath: String): List { + val books = fileSystemScanner.scanRootFolder(Paths.get(filePath)).values.flatten().map { BookWithMedia(it, Media()) } + + transientBookRepository.saveAll(books) + + return books + } + + fun analyzeAndPersist(transientBook: BookWithMedia): BookWithMedia { + val media = bookAnalyzer.analyze(transientBook.book) + + val updated = transientBook.copy(media = media) + transientBookRepository.save(updated) + + return updated + } + + @Throws( + MediaNotReadyException::class, + IndexOutOfBoundsException::class + ) + fun getBookPage(transientBook: BookWithMedia, number: Int): BookPageContent { + val pageContent = bookAnalyzer.getPageContent(transientBook, number) + val pageMediaType = transientBook.media.pages[number - 1].mediaType + + return BookPageContent(number, pageContent, pageMediaType) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/cache/TransientBookCache.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/cache/TransientBookCache.kt new file mode 100644 index 000000000..9eac7da77 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/cache/TransientBookCache.kt @@ -0,0 +1,27 @@ +package org.gotson.komga.infrastructure.cache + +import com.github.benmanes.caffeine.cache.Caffeine +import mu.KotlinLogging +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.persistence.TransientBookRepository +import org.springframework.stereotype.Service +import java.util.concurrent.TimeUnit + +private val logger = KotlinLogging.logger {} + +@Service +class TransientBookCache : TransientBookRepository { + private val cache = Caffeine.newBuilder() + .expireAfterAccess(1, TimeUnit.HOURS) + .build() + + override fun findById(transientBookId: String): BookWithMedia? = cache.getIfPresent(transientBookId) + + override fun save(transientBook: BookWithMedia) { + cache.put(transientBook.book.id, transientBook) + } + + override fun saveAll(transientBooks: Collection) { + cache.putAll(transientBooks.associateBy { it.book.id }) + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt index dbe271e54..73e45ec08 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/web/Utils.kt @@ -1,6 +1,7 @@ package org.gotson.komga.infrastructure.web import org.springframework.http.CacheControl +import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import java.net.URL import java.nio.file.Paths @@ -20,3 +21,13 @@ val cachePrivate = CacheControl .noTransform() .cachePrivate() .mustRevalidate() + +fun getMediaTypeOrDefault(mediaTypeString: String?): MediaType { + mediaTypeString?.let { + try { + return MediaType.parseMediaType(mediaTypeString) + } catch (ex: Exception) { + } + } + return MediaType.APPLICATION_OCTET_STREAM +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt index 6e119fb2e..bd3de0ecd 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt @@ -28,6 +28,7 @@ import org.gotson.komga.infrastructure.mediacontainer.ContentDetector import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam +import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault import org.gotson.komga.infrastructure.web.setCachePrivate import org.gotson.komga.interfaces.rest.dto.BookDto import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto @@ -503,14 +504,4 @@ class BookController( private fun getBookLastModified(media: Media) = media.lastModifiedDate.toInstant(ZoneOffset.UTC).toEpochMilli() - - private fun getMediaTypeOrDefault(mediaTypeString: String?): MediaType { - mediaTypeString?.let { - try { - return MediaType.parseMediaType(mediaTypeString) - } catch (ex: Exception) { - } - } - return MediaType.APPLICATION_OCTET_STREAM - } } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/TransientBooksController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/TransientBooksController.kt new file mode 100644 index 000000000..312593eb7 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/TransientBooksController.kt @@ -0,0 +1,121 @@ +package org.gotson.komga.interfaces.rest + +import com.jakewharton.byteunits.BinaryByteUnit +import mu.KotlinLogging +import org.gotson.komga.domain.model.BookWithMedia +import org.gotson.komga.domain.model.DirectoryNotFoundException +import org.gotson.komga.domain.model.MediaNotReadyException +import org.gotson.komga.domain.model.ROLE_ADMIN +import org.gotson.komga.domain.persistence.TransientBookRepository +import org.gotson.komga.domain.service.TransientBookLifecycle +import org.gotson.komga.infrastructure.web.getMediaTypeOrDefault +import org.gotson.komga.infrastructure.web.toFilePath +import org.gotson.komga.interfaces.rest.dto.PageDto +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +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.nio.file.NoSuchFileException +import java.time.LocalDateTime + +private val logger = KotlinLogging.logger {} + +@RestController +@RequestMapping("api/v1/transient-books", produces = [MediaType.APPLICATION_JSON_VALUE]) +@PreAuthorize("hasRole('$ROLE_ADMIN')") +class TransientBooksController( + private val transientBookLifecycle: TransientBookLifecycle, + private val transientBookRepository: TransientBookRepository, +) { + + @PostMapping + fun scanForTransientBooks( + @RequestBody request: ScanRequestDto + ): List = + try { + transientBookLifecycle.scanAndPersist(request.path) + .sortedBy { it.book.path() } + .map { it.toDto() } + } catch (e: DirectoryNotFoundException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.code) + } + + @PostMapping("{id}/analyze") + fun analyze( + @PathVariable id: String, + ): TransientBookDto = transientBookRepository.findById(id)?.let { + transientBookLifecycle.analyzeAndPersist(it).toDto() + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + + @GetMapping( + value = ["{id}/pages/{pageNumber}"], + produces = [MediaType.ALL_VALUE] + ) + fun getSourcePage( + @PathVariable id: String, + @PathVariable pageNumber: Int, + ): ResponseEntity = + transientBookRepository.findById(id)?.let { + try { + val pageContent = transientBookLifecycle.getBookPage(it, pageNumber) + + ResponseEntity.ok() + .contentType(getMediaTypeOrDefault(pageContent.mediaType)) + .body(pageContent.content) + } catch (ex: IndexOutOfBoundsException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Page number does not exist") + } catch (ex: MediaNotReadyException) { + throw ResponseStatusException(HttpStatus.NOT_FOUND, "Book analysis failed") + } catch (ex: NoSuchFileException) { + logger.warn(ex) { "File not found}" } + throw ResponseStatusException(HttpStatus.NOT_FOUND, "File not found, it may have moved") + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) +} + +private fun BookWithMedia.toDto() = + TransientBookDto( + id = book.id, + name = book.name, + url = book.url.toFilePath(), + fileLastModified = book.fileLastModified, + sizeBytes = book.fileSize, + status = media.status.toString(), + mediaType = media.mediaType ?: "", + pages = media.pages.mapIndexed { index, bookPage -> + PageDto( + number = index + 1, + fileName = bookPage.fileName, + mediaType = bookPage.mediaType, + width = bookPage.dimension?.width, + height = bookPage.dimension?.height, + ) + }, + files = media.files, + comment = media.comment ?: "", + ) + +data class ScanRequestDto( + val path: String, +) + +data class TransientBookDto( + val id: String, + val name: String, + val url: String, + val fileLastModified: LocalDateTime, + val sizeBytes: Long, + val size: String = BinaryByteUnit.format(sizeBytes), + val status: String, + val mediaType: String, + val pages: List, + val files: List, + val comment: String, +)