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
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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