feat(api): support for transient books

Transient books are books that are outside of a Komga library, and not persisted
This commit is contained in:
Gauthier Roebroeck 2021-04-19 17:17:37 +08:00
parent 34f77a83fc
commit 02b08932ba
9 changed files with 241 additions and 18 deletions

View file

@ -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

View file

@ -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<KotlinCompile> {
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"
)
}
}

View file

@ -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<BookWithMedia>)
}

View file

@ -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<Series, List<Book>>()
@ -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 =

View file

@ -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<BookWithMedia> {
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)
}
}

View file

@ -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<String, BookWithMedia>()
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<BookWithMedia>) {
cache.putAll(transientBooks.associateBy { it.book.id })
}
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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<TransientBookDto> =
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<ByteArray> =
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<PageDto>,
val files: List<String>,
val comment: String,
)