mirror of
https://github.com/gotson/komga.git
synced 2025-12-06 08:32:25 +01:00
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:
parent
34f77a83fc
commit
02b08932ba
9 changed files with 241 additions and 18 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
|
||||||
# Error codes
|
# Error codes
|
||||||
|
|
||||||
Code | Description
|
Code | Description
|
||||||
|
|
@ -18,3 +19,4 @@ ERR_1012 | No match for series
|
||||||
ERR_1013 | No unique match for book number within series
|
ERR_1013 | No unique match for book number within series
|
||||||
ERR_1014 | No match for book number within series
|
ERR_1014 | No match for book number within series
|
||||||
ERR_1015 | Error while deserializing ComicRack ReadingList
|
ERR_1015 | Error while deserializing ComicRack ReadingList
|
||||||
|
ERR_1016 | Directory not accessible or not a directory
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,8 @@ dependencies {
|
||||||
|
|
||||||
implementation("com.github.f4b6a3:tsid-creator:3.0.1")
|
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
|
// 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")
|
// runtimeOnly("org.xerial:sqlite-jdbc:3.32.3.2")
|
||||||
// jooqGenerator("org.xerial:sqlite-jdbc:3.32.3.2")
|
// jooqGenerator("org.xerial:sqlite-jdbc:3.32.3.2")
|
||||||
|
|
@ -107,7 +109,11 @@ tasks {
|
||||||
withType<KotlinCompile> {
|
withType<KotlinCompile> {
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "1.8"
|
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"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>)
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,8 @@ import java.nio.file.attribute.BasicFileAttributes
|
||||||
import java.nio.file.attribute.FileTime
|
import java.nio.file.attribute.FileTime
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
|
import kotlin.io.path.exists
|
||||||
|
import kotlin.io.path.readAttributes
|
||||||
import kotlin.time.measureTime
|
import kotlin.time.measureTime
|
||||||
|
|
||||||
private val logger = KotlinLogging.logger {}
|
private val logger = KotlinLogging.logger {}
|
||||||
|
|
@ -35,7 +37,7 @@ class FileSystemScanner(
|
||||||
logger.info { "Force directory modified time: $forceDirectoryModifiedTime" }
|
logger.info { "Force directory modified time: $forceDirectoryModifiedTime" }
|
||||||
|
|
||||||
if (!(Files.isDirectory(root) && Files.isReadable(root)))
|
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>>()
|
val scannedSeries = mutableMapOf<Series, List<Book>>()
|
||||||
|
|
||||||
|
|
@ -69,12 +71,7 @@ class FileSystemScanner(
|
||||||
supportedExtensions.contains(FilenameUtils.getExtension(file.fileName.toString()).toLowerCase()) &&
|
supportedExtensions.contains(FilenameUtils.getExtension(file.fileName.toString()).toLowerCase()) &&
|
||||||
!file.fileName.toString().startsWith(".")
|
!file.fileName.toString().startsWith(".")
|
||||||
) {
|
) {
|
||||||
val book = Book(
|
val book = pathToBook(file, attrs)
|
||||||
name = FilenameUtils.getBaseName(file.fileName.toString()),
|
|
||||||
url = file.toUri().toURL(),
|
|
||||||
fileLastModified = attrs.getUpdatedTime(),
|
|
||||||
fileSize = attrs.size()
|
|
||||||
)
|
|
||||||
file.parent.let { key ->
|
file.parent.let { key ->
|
||||||
if (pathToBooks.containsKey(key)) pathToBooks[key]!!.add(book)
|
if (pathToBooks.containsKey(key)) pathToBooks[key]!!.add(book)
|
||||||
else pathToBooks[key] = mutableListOf(book)
|
else pathToBooks[key] = mutableListOf(book)
|
||||||
|
|
@ -118,6 +115,20 @@ class FileSystemScanner(
|
||||||
|
|
||||||
return scannedSeries
|
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 =
|
fun BasicFileAttributes.getUpdatedTime(): LocalDateTime =
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
27
komga/src/main/kotlin/org/gotson/komga/infrastructure/cache/TransientBookCache.kt
vendored
Normal file
27
komga/src/main/kotlin/org/gotson/komga/infrastructure/cache/TransientBookCache.kt
vendored
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package org.gotson.komga.infrastructure.web
|
package org.gotson.komga.infrastructure.web
|
||||||
|
|
||||||
import org.springframework.http.CacheControl
|
import org.springframework.http.CacheControl
|
||||||
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.nio.file.Paths
|
import java.nio.file.Paths
|
||||||
|
|
@ -20,3 +21,13 @@ val cachePrivate = CacheControl
|
||||||
.noTransform()
|
.noTransform()
|
||||||
.cachePrivate()
|
.cachePrivate()
|
||||||
.mustRevalidate()
|
.mustRevalidate()
|
||||||
|
|
||||||
|
fun getMediaTypeOrDefault(mediaTypeString: String?): MediaType {
|
||||||
|
mediaTypeString?.let {
|
||||||
|
try {
|
||||||
|
return MediaType.parseMediaType(mediaTypeString)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return MediaType.APPLICATION_OCTET_STREAM
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
|
||||||
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
import org.gotson.komga.infrastructure.security.KomgaPrincipal
|
||||||
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
|
import org.gotson.komga.infrastructure.swagger.PageableAsQueryParam
|
||||||
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
|
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.infrastructure.web.setCachePrivate
|
||||||
import org.gotson.komga.interfaces.rest.dto.BookDto
|
import org.gotson.komga.interfaces.rest.dto.BookDto
|
||||||
import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto
|
import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto
|
||||||
|
|
@ -503,14 +504,4 @@ class BookController(
|
||||||
|
|
||||||
private fun getBookLastModified(media: Media) =
|
private fun getBookLastModified(media: Media) =
|
||||||
media.lastModifiedDate.toInstant(ZoneOffset.UTC).toEpochMilli()
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue