feat: add library option to choose series cover

closes #312
This commit is contained in:
Gauthier Roebroeck 2021-07-26 14:58:20 +08:00
parent f93143c9ec
commit 8e94b8e444
15 changed files with 180 additions and 90 deletions

View file

@ -0,0 +1,2 @@
alter table library
add column SERIES_COVER varchar NOT NULL DEFAULT 'FIRST';

View file

@ -24,6 +24,7 @@ data class Library(
val repairExtensions: Boolean = false,
val convertToCbz: Boolean = false,
val emptyTrashAfterScan: Boolean = false,
val seriesCover: SeriesCover = SeriesCover.FIRST,
val id: String = TsidCreator.getTsid256().toString(),
@ -31,6 +32,13 @@ data class Library(
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
) : Auditable(), Serializable {
enum class SeriesCover {
FIRST,
FIRST_UNREAD_OR_FIRST,
FIRST_UNREAD_OR_LAST,
LAST,
}
@delegate:Transient
val path: Path by lazy { this.root.toURI().toPath() }
}

View file

@ -22,6 +22,8 @@ interface BookRepository {
fun getLibraryIdOrNull(bookId: String): String?
fun getSeriesIdOrNull(bookId: String): String?
fun findFirstIdInSeriesOrNull(seriesId: String): String?
fun findLastIdInSeriesOrNull(seriesId: String): String?
fun findFirstUnreadIdInSeriesOrNull(seriesId: String, userId: String): String?
fun findAllIdsBySeriesId(seriesId: String): Collection<String>
fun findAllIdsBySeriesIds(seriesIds: Collection<String>): Collection<String>

View file

@ -61,7 +61,7 @@ class SeriesCollectionLifecycle(
toDelete.forEach { eventPublisher.publishEvent(DomainEvent.CollectionDeleted(it)) }
}
fun getThumbnailBytes(collection: SeriesCollection): ByteArray {
fun getThumbnailBytes(collection: SeriesCollection, userId: String): ByteArray {
val ids = with(mutableListOf<String>()) {
while (size < 4) {
this += collection.seriesIds.take(4)
@ -69,7 +69,7 @@ class SeriesCollectionLifecycle(
this.take(4)
}
val images = ids.mapNotNull { seriesLifecycle.getThumbnailBytes(it) }
val images = ids.mapNotNull { seriesLifecycle.getThumbnailBytes(it, userId) }
return mosaicGenerator.createMosaic(images)
}
}

View file

@ -11,6 +11,7 @@ import org.gotson.komga.domain.model.BookMetadataAggregation
import org.gotson.komga.domain.model.BookMetadataPatchCapability
import org.gotson.komga.domain.model.DomainEvent
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ReadProgress
import org.gotson.komga.domain.model.Series
@ -19,6 +20,7 @@ import org.gotson.komga.domain.model.ThumbnailSeries
import org.gotson.komga.domain.persistence.BookMetadataAggregationRepository
import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
@ -37,6 +39,7 @@ private val natSortComparator: Comparator<String> = CaseInsensitiveSimpleNatural
@Service
class SeriesLifecycle(
private val libraryRepository: LibraryRepository,
private val bookRepository: BookRepository,
private val bookLifecycle: BookLifecycle,
private val mediaRepository: MediaRepository,
@ -197,14 +200,23 @@ class SeriesLifecycle(
return selected
}
fun getThumbnailBytes(seriesId: String): ByteArray? {
fun getThumbnailBytes(seriesId: String, userId: String): ByteArray? {
getThumbnail(seriesId)?.let {
return File(it.url.toURI()).readBytes()
}
bookRepository.findFirstIdInSeriesOrNull(seriesId)?.let { bookId ->
return bookLifecycle.getThumbnailBytes(bookId)
seriesRepository.findByIdOrNull(seriesId)?.let { series ->
val bookId = when (libraryRepository.findById(series.libraryId).seriesCover) {
Library.SeriesCover.FIRST -> bookRepository.findFirstIdInSeriesOrNull(seriesId)
Library.SeriesCover.FIRST_UNREAD_OR_FIRST -> bookRepository.findFirstUnreadIdInSeriesOrNull(seriesId, userId)
?: bookRepository.findFirstIdInSeriesOrNull(seriesId)
Library.SeriesCover.FIRST_UNREAD_OR_LAST -> bookRepository.findFirstUnreadIdInSeriesOrNull(seriesId, userId)
?: bookRepository.findLastIdInSeriesOrNull(seriesId)
Library.SeriesCover.LAST -> bookRepository.findLastIdInSeriesOrNull(seriesId)
}
if (bookId != null) return bookLifecycle.getThumbnailBytes(bookId)
}
return null
}

View file

@ -27,6 +27,7 @@ class BookDao(
private val b = Tables.BOOK
private val m = Tables.MEDIA
private val d = Tables.BOOK_METADATA
private val r = Tables.READ_PROGRESS
private val sorts = mapOf(
"createdDate" to b.CREATED_DATE,
@ -139,6 +140,26 @@ class BookDao(
.limit(1)
.fetchOne(b.ID)
override fun findLastIdInSeriesOrNull(seriesId: String): String? =
dsl.select(b.ID)
.from(b)
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
.where(b.SERIES_ID.eq(seriesId))
.orderBy(d.NUMBER_SORT.desc())
.limit(1)
.fetchOne(b.ID)
override fun findFirstUnreadIdInSeriesOrNull(seriesId: String, userId: String): String? ? =
dsl.select(b.ID)
.from(b)
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
.leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(r.USER_ID.eq(userId).or(r.USER_ID.isNull))
.where(b.SERIES_ID.eq(seriesId))
.and(r.COMPLETED.isNull)
.orderBy(d.NUMBER_SORT)
.limit(1)
.fetchOne(b.ID)
override fun findAllIdsBySeriesId(seriesId: String): Collection<String> =
dsl.select(b.ID)
.from(b)

View file

@ -75,6 +75,7 @@ class LibraryDao(
.set(l.REPAIR_EXTENSIONS, library.repairExtensions)
.set(l.CONVERT_TO_CBZ, library.convertToCbz)
.set(l.EMPTY_TRASH_AFTER_SCAN, library.emptyTrashAfterScan)
.set(l.SERIES_COVER, library.seriesCover.toString())
.execute()
}
@ -97,6 +98,7 @@ class LibraryDao(
.set(l.REPAIR_EXTENSIONS, library.repairExtensions)
.set(l.CONVERT_TO_CBZ, library.convertToCbz)
.set(l.EMPTY_TRASH_AFTER_SCAN, library.emptyTrashAfterScan)
.set(l.SERIES_COVER, library.seriesCover.toString())
.set(l.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z")))
.where(l.ID.eq(library.id))
.execute()
@ -122,6 +124,7 @@ class LibraryDao(
repairExtensions = repairExtensions,
convertToCbz = convertToCbz,
emptyTrashAfterScan = emptyTrashAfterScan,
seriesCover = Library.SeriesCover.valueOf(seriesCover),
id = id,
createdDate = createdDate.toCurrentTimeZone(),
lastModifiedDate = lastModifiedDate.toCurrentTimeZone()

View file

@ -1,6 +1,5 @@
package org.gotson.komga.interfaces.rest
import mu.KotlinLogging
import org.gotson.komga.application.tasks.HIGH_PRIORITY
import org.gotson.komga.application.tasks.TaskReceiver
import org.gotson.komga.domain.model.DirectoryNotFoundException
@ -14,7 +13,11 @@ import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.domain.service.LibraryLifecycle
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.web.filePathToUrl
import org.gotson.komga.infrastructure.web.toFilePath
import org.gotson.komga.interfaces.rest.dto.LibraryCreationDto
import org.gotson.komga.interfaces.rest.dto.LibraryDto
import org.gotson.komga.interfaces.rest.dto.LibraryUpdateDto
import org.gotson.komga.interfaces.rest.dto.toDomain
import org.gotson.komga.interfaces.rest.dto.toDto
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize
@ -31,9 +34,6 @@ import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.io.FileNotFoundException
import javax.validation.Valid
import javax.validation.constraints.NotBlank
private val logger = KotlinLogging.logger {}
@RestController
@RequestMapping("api/v1/libraries", produces = [MediaType.APPLICATION_JSON_VALUE])
@ -90,6 +90,7 @@ class LibraryController(
repairExtensions = library.repairExtensions,
convertToCbz = library.convertToCbz,
emptyTrashAfterScan = library.emptyTrashAfterScan,
seriesCover = library.seriesCover.toDomain(),
)
).toDto(includeRoot = principal.user.roleAdmin)
} catch (e: Exception) {
@ -129,6 +130,7 @@ class LibraryController(
repairExtensions = library.repairExtensions,
convertToCbz = library.convertToCbz,
emptyTrashAfterScan = library.emptyTrashAfterScan,
seriesCover = library.seriesCover.toDomain(),
)
libraryLifecycle.updateLibrary(toUpdate)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@ -183,81 +185,3 @@ class LibraryController(
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
}
data class LibraryCreationDto(
@get:NotBlank val name: String,
@get:NotBlank val root: String,
val importComicInfoBook: Boolean = true,
val importComicInfoSeries: Boolean = true,
val importComicInfoCollection: Boolean = true,
val importComicInfoReadList: Boolean = true,
val importEpubBook: Boolean = true,
val importEpubSeries: Boolean = true,
val importMylarSeries: Boolean = true,
val importLocalArtwork: Boolean = true,
val importBarcodeIsbn: Boolean = true,
val scanForceModifiedTime: Boolean = false,
val scanDeep: Boolean = false,
val repairExtensions: Boolean = false,
val convertToCbz: Boolean = false,
val emptyTrashAfterScan: Boolean = false,
)
data class LibraryDto(
val id: String,
val name: String,
val root: String,
val importComicInfoBook: Boolean,
val importComicInfoSeries: Boolean,
val importComicInfoCollection: Boolean,
val importComicInfoReadList: Boolean,
val importEpubBook: Boolean,
val importEpubSeries: Boolean,
val importMylarSeries: Boolean,
val importLocalArtwork: Boolean,
val importBarcodeIsbn: Boolean,
val scanForceModifiedTime: Boolean,
val scanDeep: Boolean,
val repairExtensions: Boolean,
val convertToCbz: Boolean,
val emptyTrashAfterScan: Boolean,
)
data class LibraryUpdateDto(
@get:NotBlank val name: String,
@get:NotBlank val root: String,
val importComicInfoBook: Boolean,
val importComicInfoSeries: Boolean,
val importComicInfoCollection: Boolean,
val importComicInfoReadList: Boolean,
val importEpubBook: Boolean,
val importEpubSeries: Boolean,
val importMylarSeries: Boolean,
val importLocalArtwork: Boolean,
val importBarcodeIsbn: Boolean,
val scanForceModifiedTime: Boolean,
val scanDeep: Boolean,
val repairExtensions: Boolean,
val convertToCbz: Boolean,
val emptyTrashAfterScan: Boolean,
)
fun Library.toDto(includeRoot: Boolean) = LibraryDto(
id = id,
name = name,
root = if (includeRoot) this.root.toFilePath() else "",
importComicInfoBook = importComicInfoBook,
importComicInfoSeries = importComicInfoSeries,
importComicInfoCollection = importComicInfoCollection,
importComicInfoReadList = importComicInfoReadList,
importEpubBook = importEpubBook,
importEpubSeries = importEpubSeries,
importMylarSeries = importMylarSeries,
importLocalArtwork = importLocalArtwork,
importBarcodeIsbn = importBarcodeIsbn,
scanForceModifiedTime = scanForceModifiedTime,
scanDeep = scanDeep,
repairExtensions = repairExtensions,
convertToCbz = convertToCbz,
emptyTrashAfterScan = emptyTrashAfterScan,
)

View file

@ -103,7 +103,7 @@ class SeriesCollectionController(
collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePrivate())
.body(collectionLifecycle.getThumbnailBytes(it))
.body(collectionLifecycle.getThumbnailBytes(it, principal.user.id))
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}

View file

@ -325,7 +325,7 @@ class SeriesController(
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return seriesLifecycle.getThumbnailBytes(seriesId) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return seriesLifecycle.getThumbnailBytes(seriesId, principal.user.id) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@PageableAsQueryParam

View file

@ -0,0 +1,23 @@
package org.gotson.komga.interfaces.rest.dto
import javax.validation.constraints.NotBlank
data class LibraryCreationDto(
@get:NotBlank val name: String,
@get:NotBlank val root: String,
val importComicInfoBook: Boolean = true,
val importComicInfoSeries: Boolean = true,
val importComicInfoCollection: Boolean = true,
val importComicInfoReadList: Boolean = true,
val importEpubBook: Boolean = true,
val importEpubSeries: Boolean = true,
val importMylarSeries: Boolean = true,
val importLocalArtwork: Boolean = true,
val importBarcodeIsbn: Boolean = true,
val scanForceModifiedTime: Boolean = false,
val scanDeep: Boolean = false,
val repairExtensions: Boolean = false,
val convertToCbz: Boolean = false,
val emptyTrashAfterScan: Boolean = false,
val seriesCover: SeriesCoverDto = SeriesCoverDto.FIRST,
)

View file

@ -0,0 +1,46 @@
package org.gotson.komga.interfaces.rest.dto
import org.gotson.komga.domain.model.Library
import org.gotson.komga.infrastructure.web.toFilePath
data class LibraryDto(
val id: String,
val name: String,
val root: String,
val importComicInfoBook: Boolean,
val importComicInfoSeries: Boolean,
val importComicInfoCollection: Boolean,
val importComicInfoReadList: Boolean,
val importEpubBook: Boolean,
val importEpubSeries: Boolean,
val importMylarSeries: Boolean,
val importLocalArtwork: Boolean,
val importBarcodeIsbn: Boolean,
val scanForceModifiedTime: Boolean,
val scanDeep: Boolean,
val repairExtensions: Boolean,
val convertToCbz: Boolean,
val emptyTrashAfterScan: Boolean,
val seriesCover: SeriesCoverDto,
)
fun Library.toDto(includeRoot: Boolean) = LibraryDto(
id = id,
name = name,
root = if (includeRoot) this.root.toFilePath() else "",
importComicInfoBook = importComicInfoBook,
importComicInfoSeries = importComicInfoSeries,
importComicInfoCollection = importComicInfoCollection,
importComicInfoReadList = importComicInfoReadList,
importEpubBook = importEpubBook,
importEpubSeries = importEpubSeries,
importMylarSeries = importMylarSeries,
importLocalArtwork = importLocalArtwork,
importBarcodeIsbn = importBarcodeIsbn,
scanForceModifiedTime = scanForceModifiedTime,
scanDeep = scanDeep,
repairExtensions = repairExtensions,
convertToCbz = convertToCbz,
emptyTrashAfterScan = emptyTrashAfterScan,
seriesCover = seriesCover.toDto(),
)

View file

@ -0,0 +1,23 @@
package org.gotson.komga.interfaces.rest.dto
import javax.validation.constraints.NotBlank
data class LibraryUpdateDto(
@get:NotBlank val name: String,
@get:NotBlank val root: String,
val importComicInfoBook: Boolean,
val importComicInfoSeries: Boolean,
val importComicInfoCollection: Boolean,
val importComicInfoReadList: Boolean,
val importEpubBook: Boolean,
val importEpubSeries: Boolean,
val importMylarSeries: Boolean,
val importLocalArtwork: Boolean,
val importBarcodeIsbn: Boolean,
val scanForceModifiedTime: Boolean,
val scanDeep: Boolean,
val repairExtensions: Boolean,
val convertToCbz: Boolean,
val emptyTrashAfterScan: Boolean,
val seriesCover: SeriesCoverDto,
)

View file

@ -0,0 +1,24 @@
package org.gotson.komga.interfaces.rest.dto
import org.gotson.komga.domain.model.Library
enum class SeriesCoverDto {
FIRST,
FIRST_UNREAD_OR_FIRST,
FIRST_UNREAD_OR_LAST,
LAST,
}
fun Library.SeriesCover.toDto() = when (this) {
Library.SeriesCover.FIRST -> SeriesCoverDto.FIRST
Library.SeriesCover.FIRST_UNREAD_OR_FIRST -> SeriesCoverDto.FIRST_UNREAD_OR_FIRST
Library.SeriesCover.FIRST_UNREAD_OR_LAST -> SeriesCoverDto.FIRST_UNREAD_OR_LAST
Library.SeriesCover.LAST -> SeriesCoverDto.LAST
}
fun SeriesCoverDto.toDomain() = when (this) {
SeriesCoverDto.FIRST -> Library.SeriesCover.FIRST
SeriesCoverDto.FIRST_UNREAD_OR_FIRST -> Library.SeriesCover.FIRST_UNREAD_OR_FIRST
SeriesCoverDto.FIRST_UNREAD_OR_LAST -> Library.SeriesCover.FIRST_UNREAD_OR_LAST
SeriesCoverDto.LAST -> Library.SeriesCover.LAST
}

View file

@ -67,6 +67,7 @@ class LibraryDaoTest(
repairExtensions = true,
convertToCbz = true,
emptyTrashAfterScan = true,
seriesCover = Library.SeriesCover.LAST,
)
}
@ -93,6 +94,7 @@ class LibraryDaoTest(
assertThat(modified.repairExtensions).isEqualTo(updated.repairExtensions)
assertThat(modified.convertToCbz).isEqualTo(updated.convertToCbz)
assertThat(modified.emptyTrashAfterScan).isEqualTo(updated.emptyTrashAfterScan)
assertThat(modified.seriesCover).isEqualTo(updated.seriesCover)
}
@Test