feat: read lists

a read list is a collection of books
read lists can be managed in the same way collections are
metadata will be optionally imported from ComicInfo to create read lists

closes #106
This commit is contained in:
Gauthier Roebroeck 2020-08-20 10:10:45 +08:00
parent 728d5f4c7d
commit f0c864f4eb
26 changed files with 1422 additions and 50 deletions

View file

@ -0,0 +1,20 @@
CREATE TABLE READLIST
(
ID varchar NOT NULL PRIMARY KEY,
NAME varchar NOT NULL,
BOOK_COUNT int NOT NULL,
CREATED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
LAST_MODIFIED_DATE datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE READLIST_BOOK
(
READLIST_ID varchar NOT NULL,
BOOK_ID varchar NOT NULL,
NUMBER int NOT NULL,
PRIMARY KEY (READLIST_ID, BOOK_ID),
FOREIGN KEY (READLIST_ID) REFERENCES READLIST (ID),
FOREIGN KEY (BOOK_ID) REFERENCES BOOK (ID)
);
alter table library
add column IMPORT_COMICINFO_READLIST boolean NOT NULL DEFAULT 1;

View file

@ -11,5 +11,8 @@ data class BookMetadataPatch(
val publisher: String?,
val ageRating: Int?,
val releaseDate: LocalDate?,
val authors: List<Author>?
val authors: List<Author>?,
val readList: String?,
val readListNumber: Int?
)

View file

@ -12,6 +12,7 @@ data class Library(
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 importLocalArtwork: Boolean = true,

View file

@ -0,0 +1,21 @@
package org.gotson.komga.domain.model
import com.github.f4b6a3.tsid.TsidCreator
import java.time.LocalDateTime
import java.util.SortedMap
data class ReadList(
val name: String,
val bookIds: SortedMap<Int, String> = sortedMapOf(),
val id: String = TsidCreator.getTsidString256(),
override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = LocalDateTime.now(),
/**
* Indicates that the bookIds have been filtered and is not exhaustive.
*/
val filtered: Boolean = false
) : Auditable()

View file

@ -0,0 +1,41 @@
package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.ReadList
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
interface ReadListRepository {
fun findByIdOrNull(readListId: String): ReadList?
fun findAll(search: String? = null, pageable: Pageable): Page<ReadList>
/**
* Find one ReadList by readListId,
* optionally with only bookIds filtered by the provided filterOnLibraryIds.
*/
fun findByIdOrNull(readListId: String, filterOnLibraryIds: Collection<String>?): ReadList?
/**
* Find all ReadList with at least one Book belonging to the provided belongsToLibraryIds,
* optionally with only bookIds filtered by the provided filterOnLibraryIds.
*/
fun findAllByLibraries(belongsToLibraryIds: Collection<String>, filterOnLibraryIds: Collection<String>?, search: String? = null, pageable: Pageable): Page<ReadList>
/**
* Find all ReadList that contains the provided containsBookId,
* optionally with only bookIds filtered by the provided filterOnLibraryIds.
*/
fun findAllByBook(containsBookId: String, filterOnLibraryIds: Collection<String>?): Collection<ReadList>
fun findByNameOrNull(name: String): ReadList?
fun insert(readList: ReadList)
fun update(readList: ReadList)
fun removeBookFromAll(bookId: String)
fun removeBookFromAll(bookIds: Collection<String>)
fun delete(readListId: String)
fun deleteAll()
fun existsByName(name: String): Boolean
}

View file

@ -12,6 +12,7 @@ import org.gotson.komga.domain.model.ThumbnailBook
import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.domain.persistence.ThumbnailBookRepository
import org.gotson.komga.infrastructure.image.ImageConverter
@ -30,6 +31,7 @@ class BookLifecycle(
private val bookMetadataRepository: BookMetadataRepository,
private val readProgressRepository: ReadProgressRepository,
private val thumbnailBookRepository: ThumbnailBookRepository,
private val readListRepository: ReadListRepository,
private val bookAnalyzer: BookAnalyzer,
private val imageConverter: ImageConverter
) {
@ -195,6 +197,8 @@ class BookLifecycle(
logger.info { "Delete book id: $bookId" }
readProgressRepository.deleteByBookId(bookId)
readListRepository.removeBookFromAll(bookId)
mediaRepository.delete(bookId)
thumbnailBookRepository.deleteByBookId(bookId)
bookMetadataRepository.delete(bookId)
@ -206,6 +210,8 @@ class BookLifecycle(
logger.info { "Delete all books: $bookIds" }
readProgressRepository.deleteByBookIds(bookIds)
readListRepository.removeBookFromAll(bookIds)
mediaRepository.deleteByBookIds(bookIds)
thumbnailBookRepository.deleteByBookIds(bookIds)
bookMetadataRepository.deleteByBookIds(bookIds)

View file

@ -2,6 +2,7 @@ package org.gotson.komga.domain.service
import mu.KotlinLogging
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.model.SeriesCollection
import org.gotson.komga.domain.model.SeriesMetadataPatch
@ -9,6 +10,7 @@ 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.ReadListRepository
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.infrastructure.metadata.BookMetadataProvider
@ -34,6 +36,8 @@ class MetadataLifecycle(
private val seriesLifecycle: SeriesLifecycle,
private val collectionRepository: SeriesCollectionRepository,
private val collectionLifecycle: SeriesCollectionLifecycle,
private val readListRepository: ReadListRepository,
private val readListLifecycle: ReadListLifecycle,
private val localArtworkProvider: LocalArtworkProvider
) {
@ -45,20 +49,64 @@ class MetadataLifecycle(
bookMetadataProviders.forEach { provider ->
when {
provider is ComicInfoProvider && !library.importComicInfoBook -> logger.info { "Library is not set to import book metadata from ComicInfo, skipping" }
provider is ComicInfoProvider && !library.importComicInfoBook && !library.importComicInfoReadList -> logger.info { "Library is not set to import book and read lists metadata from ComicInfo, skipping" }
provider is EpubMetadataProvider && !library.importEpubBook -> logger.info { "Library is not set to import book metadata from Epub, skipping" }
else -> {
logger.debug { "Provider: $provider" }
provider.getBookMetadataFromBook(book, media)?.let { bPatch ->
val patch = provider.getBookMetadataFromBook(book, media)
bookMetadataRepository.findById(book.id).let {
logger.debug { "Original metadata: $it" }
val patched = metadataApplier.apply(bPatch, it)
logger.debug { "Patched metadata: $patched" }
// handle book metadata
if ((provider is ComicInfoProvider && library.importComicInfoBook) ||
(provider is EpubMetadataProvider && library.importEpubBook)) {
patch?.let { bPatch ->
bookMetadataRepository.findById(book.id).let {
logger.debug { "Original metadata: $it" }
val patched = metadataApplier.apply(bPatch, it)
logger.debug { "Patched metadata: $patched" }
bookMetadataRepository.update(patched)
bookMetadataRepository.update(patched)
}
}
}
// handle read lists
if (provider is ComicInfoProvider && library.importComicInfoReadList) {
patch?.let { bPatch ->
val readList = bPatch.readList
val readListNumber = bPatch.readListNumber
if (readList != null) {
readListRepository.findByNameOrNull(readList).let { existing ->
if (existing != null) {
if (existing.bookIds.containsValue(book.id))
logger.debug { "Book is already in existing readlist '${existing.name}'" }
else {
val map = existing.bookIds.toSortedMap()
val key = if (readListNumber != null && existing.bookIds.containsKey(readListNumber)) {
logger.debug { "Existing readlist '${existing.name}' already contains a book at position $readListNumber, adding book '${book.name}' at the end" }
existing.bookIds.lastKey() + 1
} else {
logger.debug { "Adding book '${book.name}' to existing readlist '${existing.name}'" }
readListNumber ?: existing.bookIds.lastKey() + 1
}
map[key] = book.id
readListLifecycle.updateReadList(
existing.copy(bookIds = map)
)
}
} else {
logger.debug { "Adding book '${book.name}' to new readlist '$readList'" }
readListLifecycle.addReadList(ReadList(
name = readList,
bookIds = mapOf((readListNumber ?: 0) to book.id).toSortedMap()
))
}
}
}
}
}
}
}
}
@ -85,7 +133,7 @@ class MetadataLifecycle(
// handle series metadata
if ((provider is ComicInfoProvider && library.importComicInfoSeries) ||
(provider is EpubMetadataProvider && !library.importEpubSeries)) {
(provider is EpubMetadataProvider && library.importEpubSeries)) {
val title = patches.uniqueOrNull { it.title }
val titleSort = patches.uniqueOrNull { it.titleSort }
val status = patches.uniqueOrNull { it.status }
@ -113,15 +161,15 @@ class MetadataLifecycle(
collectionRepository.findByNameOrNull(collection).let { existing ->
if (existing != null) {
if (existing.seriesIds.contains(series.id))
logger.debug { "Series is already in existing collection ${existing.name}" }
logger.debug { "Series is already in existing collection '${existing.name}'" }
else {
logger.debug { "Adding series ${series.name} to existing collection ${existing.name}" }
logger.debug { "Adding series '${series.name}' to existing collection '${existing.name}'" }
collectionLifecycle.updateCollection(
existing.copy(seriesIds = existing.seriesIds + series.id)
)
}
} else {
logger.debug { "Adding series ${series.name} to new collection $collection" }
logger.debug { "Adding series '${series.name}' to new collection '$collection'" }
collectionLifecycle.addCollection(SeriesCollection(
name = collection,
seriesIds = listOf(series.id)

View file

@ -0,0 +1,58 @@
package org.gotson.komga.domain.service
import mu.KotlinLogging
import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.infrastructure.image.MosaicGenerator
import org.springframework.stereotype.Service
private val logger = KotlinLogging.logger {}
@Service
class ReadListLifecycle(
private val readListRepository: ReadListRepository,
private val bookLifecycle: BookLifecycle,
private val mosaicGenerator: MosaicGenerator
) {
@Throws(
DuplicateNameException::class
)
fun addReadList(readList: ReadList): ReadList {
logger.info { "Adding new read list: $readList" }
if (readListRepository.existsByName(readList.name))
throw DuplicateNameException("Read list name already exists")
readListRepository.insert(readList)
return readListRepository.findByIdOrNull(readList.id)!!
}
fun updateReadList(toUpdate: ReadList) {
val existing = readListRepository.findByIdOrNull(toUpdate.id)
?: throw IllegalArgumentException("Cannot update read list that does not exist")
if (existing.name != toUpdate.name && readListRepository.existsByName(toUpdate.name))
throw DuplicateNameException("Read list name already exists")
readListRepository.update(toUpdate)
}
fun deleteReadList(readListId: String) {
readListRepository.delete(readListId)
}
fun getThumbnailBytes(readList: ReadList): ByteArray {
val ids = with(mutableListOf<String>()) {
while (size < 4) {
this += readList.bookIds.values.take(4)
}
this.take(4)
}
val images = ids.mapNotNull { bookLifecycle.getThumbnailBytes(it) }
return mosaicGenerator.createMosaic(images)
}
}

View file

@ -39,6 +39,7 @@ class BookDtoDao(
private val r = Tables.READ_PROGRESS
private val a = Tables.BOOK_METADATA_AUTHOR
private val s = Tables.SERIES
private val rlb = Tables.READLIST_BOOK
private val sorts = mapOf(
"name" to DSL.lower(b.NAME),
@ -52,24 +53,38 @@ class BookDtoDao(
"media.comment" to DSL.lower(m.COMMENT),
"media.mediaType" to DSL.lower(m.MEDIA_TYPE),
"metadata.numberSort" to d.NUMBER_SORT,
"readProgress.lastModified" to r.LAST_MODIFIED_DATE
"readProgress.lastModified" to r.LAST_MODIFIED_DATE,
"readList.number" to rlb.NUMBER
)
override fun findAll(search: BookSearchWithReadProgress, userId: String, pageable: Pageable): Page<BookDto> {
val conditions = search.toCondition()
val count = dsl.selectCount()
return findAll(conditions, userId, pageable)
}
override fun findByReadListId(readListId: String, userId: String, pageable: Pageable): Page<BookDto> {
val conditions = rlb.READLIST_ID.eq(readListId)
return findAll(conditions, userId, pageable, selectReadListNumber = true)
}
private fun findAll(conditions: Condition, userId: String, pageable: Pageable, selectReadListNumber: Boolean = false): Page<BookDto> {
val count = dsl.selectDistinct(b.ID)
.from(b)
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
.leftJoin(r).on(b.ID.eq(r.BOOK_ID))
.and(readProgressCondition(userId))
.leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID))
.where(conditions)
.fetchOne(0, Long::class.java)
.groupBy(b.ID)
.fetch()
.size
val orderBy = pageable.sort.toOrderBy(sorts)
val dtos = selectBase(userId)
val dtos = selectBase(userId, selectReadListNumber)
.where(conditions)
.orderBy(orderBy)
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
@ -79,7 +94,7 @@ class BookDtoDao(
return PageImpl(
dtos,
if (pageable.isPaged) PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort)
else PageRequest.of(0, maxOf(count.toInt(), 20), pageSort),
else PageRequest.of(0, maxOf(count, 20), pageSort),
count.toLong()
)
}
@ -151,17 +166,19 @@ class BookDtoDao(
.firstOrNull()
}
private fun selectBase(userId: String) =
dsl.select(
private fun selectBase(userId: String, selectReadListNumber: Boolean = false) =
dsl.selectDistinct(
*b.fields(),
*m.fields(),
*d.fields(),
*r.fields()
).from(b)
).apply { if (selectReadListNumber) select(rlb.NUMBER) }
.from(b)
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
.leftJoin(r).on(b.ID.eq(r.BOOK_ID))
.and(readProgressCondition(userId))
.leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID))
private fun ResultQuery<Record>.fetchAndMap() =
fetch()

View file

@ -70,6 +70,7 @@ class LibraryDao(
.set(l.IMPORT_COMICINFO_BOOK, library.importComicInfoBook)
.set(l.IMPORT_COMICINFO_SERIES, library.importComicInfoSeries)
.set(l.IMPORT_COMICINFO_COLLECTION, library.importComicInfoCollection)
.set(l.IMPORT_COMICINFO_READLIST, library.importComicInfoReadList)
.set(l.IMPORT_EPUB_BOOK, library.importEpubBook)
.set(l.IMPORT_EPUB_SERIES, library.importEpubSeries)
.set(l.IMPORT_LOCAL_ARTWORK, library.importLocalArtwork)
@ -85,6 +86,7 @@ class LibraryDao(
.set(l.IMPORT_COMICINFO_BOOK, library.importComicInfoBook)
.set(l.IMPORT_COMICINFO_SERIES, library.importComicInfoSeries)
.set(l.IMPORT_COMICINFO_COLLECTION, library.importComicInfoCollection)
.set(l.IMPORT_COMICINFO_READLIST, library.importComicInfoReadList)
.set(l.IMPORT_EPUB_BOOK, library.importEpubBook)
.set(l.IMPORT_EPUB_SERIES, library.importEpubSeries)
.set(l.IMPORT_LOCAL_ARTWORK, library.importLocalArtwork)
@ -105,6 +107,7 @@ class LibraryDao(
importComicInfoBook = importComicinfoBook,
importComicInfoSeries = importComicinfoSeries,
importComicInfoCollection = importComicinfoCollection,
importComicInfoReadList = importComicinfoReadlist,
importEpubBook = importEpubBook,
importEpubSeries = importEpubSeries,
importLocalArtwork = importLocalArtwork,

View file

@ -0,0 +1,233 @@
package org.gotson.komga.infrastructure.jooq
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.jooq.Tables
import org.gotson.komga.jooq.tables.records.ReadlistRecord
import org.jooq.DSLContext
import org.jooq.Record
import org.jooq.ResultQuery
import org.jooq.impl.DSL
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.SortedMap
@Component
class ReadListDao(
private val dsl: DSLContext
) : ReadListRepository {
private val rl = Tables.READLIST
private val rlb = Tables.READLIST_BOOK
private val b = Tables.BOOK
private val sorts = mapOf(
"name" to DSL.lower(rl.NAME)
)
override fun findByIdOrNull(readListId: String): ReadList? =
selectBase()
.where(rl.ID.eq(readListId))
.fetchAndMap(null)
.firstOrNull()
override fun findByIdOrNull(readListId: String, filterOnLibraryIds: Collection<String>?): ReadList? =
selectBase()
.where(rl.ID.eq(readListId))
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
.fetchAndMap(filterOnLibraryIds)
.firstOrNull()
override fun findAll(search: String?, pageable: Pageable): Page<ReadList> {
val conditions = search?.let { rl.NAME.containsIgnoreCase(it) }
?: DSL.trueCondition()
val count = dsl.selectCount()
.from(rl)
.where(conditions)
.fetchOne(0, Long::class.java)
val orderBy = pageable.sort.toOrderBy(sorts)
val items = selectBase()
.where(conditions)
.orderBy(orderBy)
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
.fetchAndMap(null)
val pageSort = if (orderBy.size > 1) pageable.sort else Sort.unsorted()
return PageImpl(
items,
if (pageable.isPaged) PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort)
else PageRequest.of(0, maxOf(count.toInt(), 20), pageSort),
count.toLong()
)
}
override fun findAllByLibraries(belongsToLibraryIds: Collection<String>, filterOnLibraryIds: Collection<String>?, search: String?, pageable: Pageable): Page<ReadList> {
val ids = dsl.selectDistinct(rl.ID)
.from(rl)
.leftJoin(rlb).on(rl.ID.eq(rlb.READLIST_ID))
.leftJoin(b).on(rlb.BOOK_ID.eq(b.ID))
.where(b.LIBRARY_ID.`in`(belongsToLibraryIds))
.apply { search?.let { and(rl.NAME.containsIgnoreCase(it)) } }
.fetch(0, String::class.java)
val count = ids.size
val orderBy = pageable.sort.toOrderBy(sorts)
val items = selectBase()
.where(rl.ID.`in`(ids))
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
.apply { search?.let { and(rl.NAME.containsIgnoreCase(it)) } }
.orderBy(orderBy)
.apply { if (pageable.isPaged) limit(pageable.pageSize).offset(pageable.offset) }
.fetchAndMap(filterOnLibraryIds)
val pageSort = if (orderBy.size > 1) pageable.sort else Sort.unsorted()
return PageImpl(
items,
if (pageable.isPaged) PageRequest.of(pageable.pageNumber, pageable.pageSize, pageSort)
else PageRequest.of(0, maxOf(count, 20), pageSort),
count.toLong()
)
}
override fun findAllByBook(containsBookId: String, filterOnLibraryIds: Collection<String>?): Collection<ReadList> {
val ids = dsl.select(rl.ID)
.from(rl)
.leftJoin(rlb).on(rl.ID.eq(rlb.READLIST_ID))
.where(rlb.BOOK_ID.eq(containsBookId))
.fetch(0, String::class.java)
return selectBase()
.where(rl.ID.`in`(ids))
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
.fetchAndMap(filterOnLibraryIds)
}
override fun findByNameOrNull(name: String): ReadList? =
selectBase()
.where(rl.NAME.equalIgnoreCase(name))
.fetchAndMap(null)
.firstOrNull()
private fun selectBase() =
dsl.selectDistinct(*rl.fields())
.from(rl)
.leftJoin(rlb).on(rl.ID.eq(rlb.READLIST_ID))
.leftJoin(b).on(rlb.BOOK_ID.eq(b.ID))
private fun ResultQuery<Record>.fetchAndMap(filterOnLibraryIds: Collection<String>?): List<ReadList> =
fetchInto(rl)
.map { rr ->
val bookIds = dsl.select(*rlb.fields())
.from(rlb)
.leftJoin(b).on(rlb.BOOK_ID.eq(b.ID))
.where(rlb.READLIST_ID.eq(rr.id))
.apply { filterOnLibraryIds?.let { and(b.LIBRARY_ID.`in`(it)) } }
.orderBy(rlb.NUMBER.asc())
.fetchInto(rlb)
.mapNotNull { it.number to it.bookId }
.toMap().toSortedMap()
rr.toDomain(bookIds)
}
override fun insert(readList: ReadList) {
dsl.transaction { config ->
config.dsl().insertInto(rl)
.set(rl.ID, readList.id)
.set(rl.NAME, readList.name)
.set(rl.BOOK_COUNT, readList.bookIds.size)
.execute()
insertBooks(config.dsl(), readList)
}
}
private fun insertBooks(dsl: DSLContext, readList: ReadList) {
readList.bookIds.map { (index, id) ->
dsl.insertInto(rlb)
.set(rlb.READLIST_ID, readList.id)
.set(rlb.BOOK_ID, id)
.set(rlb.NUMBER, index)
.execute()
}
}
override fun update(readList: ReadList) {
dsl.transaction { config ->
with(config.dsl()) {
update(rl)
.set(rl.NAME, readList.name)
.set(rl.BOOK_COUNT, readList.bookIds.size)
.set(rl.LAST_MODIFIED_DATE, LocalDateTime.now(ZoneId.of("Z")))
.where(rl.ID.eq(readList.id))
.execute()
deleteFrom(rlb).where(rlb.READLIST_ID.eq(readList.id)).execute()
insertBooks(config.dsl(), readList)
}
}
}
override fun removeBookFromAll(bookId: String) {
dsl.deleteFrom(rlb)
.where(rlb.BOOK_ID.eq(bookId))
.execute()
}
override fun removeBookFromAll(bookIds: Collection<String>) {
dsl.deleteFrom(rlb)
.where(rlb.BOOK_ID.`in`(bookIds))
.execute()
}
override fun delete(readListId: String) {
dsl.transaction { config ->
with(config.dsl())
{
deleteFrom(rlb).where(rlb.READLIST_ID.eq(readListId)).execute()
deleteFrom(rl).where(rl.ID.eq(readListId)).execute()
}
}
}
override fun deleteAll() {
dsl.transaction { config ->
with(config.dsl())
{
deleteFrom(rlb).execute()
deleteFrom(rl).execute()
}
}
}
override fun existsByName(name: String): Boolean =
dsl.fetchExists(
dsl.selectFrom(rl)
.where(rl.NAME.equalIgnoreCase(name))
)
private fun ReadlistRecord.toDomain(bookIds: SortedMap<Int, String>) =
ReadList(
name = name,
bookIds = bookIds,
id = id,
createdDate = createdDate.toCurrentTimeZone(),
lastModifiedDate = lastModifiedDate.toCurrentTimeZone(),
filtered = bookCount != bookIds.size
)
}

View file

@ -0,0 +1,6 @@
package org.gotson.komga.infrastructure.language
import java.util.SortedMap
fun <T> List<T>.toIndexedMap(): SortedMap<Int, T> =
mapIndexed { i, e -> i to e }.toMap().toSortedMap()

View file

@ -49,15 +49,17 @@ class ComicInfoProvider(
}
return BookMetadataPatch(
comicInfo.title,
comicInfo.summary,
comicInfo.number,
comicInfo.number?.toFloatOrNull(),
readingDirection,
comicInfo.publisher,
comicInfo.ageRating?.ageRating,
releaseDate,
authors.ifEmpty { null }
title = comicInfo.title,
summary = comicInfo.summary,
number = comicInfo.number,
numberSort = comicInfo.number?.toFloatOrNull(),
readingDirection = readingDirection,
publisher = comicInfo.publisher,
ageRating = comicInfo.ageRating?.ageRating,
releaseDate = releaseDate,
authors = authors.ifEmpty { null },
readList = comicInfo.alternateSeries ?: comicInfo.storyArc,
readListNumber = comicInfo.alternateNumber?.toIntOrNull() ?: comicInfo.storyArcNumber?.toIntOrNull()
)
}
return null

View file

@ -111,6 +111,9 @@ class ComicInfo {
@JsonProperty(value = "StoryArc")
var storyArc: String? = null
@JsonProperty(value = "StoryArcNumber")
var storyArcNumber: String? = null
@JsonProperty(value = "SeriesGroup")
var seriesGroup: String? = null

View file

@ -67,7 +67,9 @@ class EpubMetadataProvider(
publisher = publisher,
ageRating = null,
releaseDate = date,
authors = authors
authors = authors,
readList = null,
readListNumber = null
)
}
return null

View file

@ -6,6 +6,7 @@ import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.Series
import org.gotson.komga.domain.model.SeriesCollection
import org.gotson.komga.domain.model.SeriesMetadata
@ -14,6 +15,7 @@ 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.ReadListRepository
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.domain.persistence.SeriesMetadataRepository
import org.gotson.komga.domain.persistence.SeriesRepository
@ -59,12 +61,14 @@ private const val ROUTE_SERIES_ALL = "series"
private const val ROUTE_SERIES_LATEST = "series/latest"
private const val ROUTE_LIBRARIES_ALL = "libraries"
private const val ROUTE_COLLECTIONS_ALL = "collections"
private const val ROUTE_READLISTS_ALL = "readlists"
private const val ROUTE_SEARCH = "search"
private const val ID_SERIES_ALL = "allSeries"
private const val ID_SERIES_LATEST = "latestSeries"
private const val ID_LIBRARIES_ALL = "allLibraries"
private const val ID_COLLECTIONS_ALL = "allCollections"
private const val ID_READLISTS_ALL = "allReadLists"
@RestController
@RequestMapping(value = [ROUTE_BASE], produces = [MediaType.APPLICATION_ATOM_XML_VALUE, MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_XML_VALUE])
@ -72,6 +76,7 @@ class OpdsController(
servletContext: ServletContext,
private val libraryRepository: LibraryRepository,
private val collectionRepository: SeriesCollectionRepository,
private val readListRepository: ReadListRepository,
private val seriesRepository: SeriesRepository,
private val seriesMetadataRepository: SeriesMetadataRepository,
private val bookRepository: BookRepository,
@ -127,6 +132,13 @@ class OpdsController(
id = ID_COLLECTIONS_ALL,
content = "Browse by collection",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "$routeBase$ROUTE_COLLECTIONS_ALL")
),
OpdsEntryNavigation(
title = "All read lists",
updated = ZonedDateTime.now(),
id = ID_READLISTS_ALL,
content = "Browse by read lists",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "$routeBase$ROUTE_READLISTS_ALL")
)
)
)
@ -245,6 +257,30 @@ class OpdsController(
)
}
@GetMapping(ROUTE_READLISTS_ALL)
fun getReadLists(
@AuthenticationPrincipal principal: KomgaPrincipal
): OpdsFeed {
val pageRequest = UnpagedSorted(Sort.by(Sort.Order.asc("name")))
val readLists =
if (principal.user.sharedAllLibraries) {
readListRepository.findAll(pageable = pageRequest)
} else {
readListRepository.findAllByLibraries(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds, pageable = pageRequest)
}
return OpdsFeedNavigation(
id = ID_READLISTS_ALL,
title = "All read lists",
updated = ZonedDateTime.now(),
author = komgaAuthor,
links = listOf(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "$routeBase$ROUTE_READLISTS_ALL"),
linkStart
),
entries = readLists.content.map { it.toOpdsEntry() }
)
}
@GetMapping("series/{id}")
fun getOneSeries(
@AuthenticationPrincipal principal: KomgaPrincipal,
@ -266,7 +302,7 @@ class OpdsController(
.map { it.toOpdsEntry(shouldPrependBookNumbers(userAgent)) }
OpdsFeedAcquisition(
id = series.id.toString(),
id = series.id,
title = metadata.title,
updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
author = komgaAuthor,
@ -294,7 +330,7 @@ class OpdsController(
.map { it.toOpdsEntry() }
OpdsFeedNavigation(
id = library.id.toString(),
id = library.id,
title = library.name,
updated = library.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
author = komgaAuthor,
@ -324,7 +360,7 @@ class OpdsController(
}
OpdsFeedNavigation(
id = collection.id.toString(),
id = collection.id,
title = collection.name,
updated = collection.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
author = komgaAuthor,
@ -337,19 +373,46 @@ class OpdsController(
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@GetMapping("readlists/{id}")
fun getOneReadList(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable id: String
): OpdsFeed {
return readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList ->
val books = readList.bookIds.values.mapNotNull { bookRepository.findByIdOrNull(it) }
.map { BookWithInfo(it, mediaRepository.findById(it.id), bookMetadataRepository.findById(it.id)) }
val entries = books.mapIndexed { index, it ->
it.toOpdsEntry(prependNumber = false, prepend = index + 1)
}
OpdsFeedAcquisition(
id = readList.id,
title = readList.name,
updated = readList.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
author = komgaAuthor,
links = listOf(
OpdsLinkFeedNavigation(OpdsLinkRel.SELF, "${routeBase}readlists/$id"),
linkStart
),
entries = entries
)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
private fun SeriesWithInfo.toOpdsEntry(prepend: Int? = null): OpdsEntryNavigation {
val pre = prepend?.let { decimalFormat.format(it) + " - " } ?: ""
return OpdsEntryNavigation(
title = pre + metadata.title,
updated = series.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
id = series.id.toString(),
id = series.id,
content = "",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}series/${series.id}")
)
}
private fun BookWithInfo.toOpdsEntry(prependNumber: Boolean): OpdsEntryAcquisition {
private fun BookWithInfo.toOpdsEntry(prependNumber: Boolean, prepend: Int? = null): OpdsEntryAcquisition {
val mediaTypes = media.pages.map { it.mediaType }.distinct()
val opdsLinkPageStreaming = if (mediaTypes.size == 1 && mediaTypes.first() in opdsPseSupportedFormats) {
@ -358,10 +421,11 @@ class OpdsController(
OpdsLinkPageStreaming("image/jpeg", "${routeBase}books/${book.id}/pages/{pageNumber}?convert=jpeg&zero_based=true", media.pages.size)
}
val pre = prepend?.let { decimalFormat.format(it) + " - " } ?: ""
return OpdsEntryAcquisition(
title = "${if (prependNumber) "${decimalFormat.format(metadata.numberSort)} - " else ""}${metadata.title}",
title = "$pre${if (prependNumber) "${decimalFormat.format(metadata.numberSort)} - " else ""}${metadata.title}",
updated = book.lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
id = book.id.toString(),
id = book.id,
content = run {
var content = "${book.fileExtension().toUpperCase()} - ${book.fileSizeHumanReadable()}"
if (metadata.summary.isNotBlank())
@ -378,25 +442,32 @@ class OpdsController(
)
}
private fun Library.toOpdsEntry(): OpdsEntryNavigation {
return OpdsEntryNavigation(
private fun Library.toOpdsEntry(): OpdsEntryNavigation =
OpdsEntryNavigation(
title = name,
updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
id = id.toString(),
id = id,
content = "",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}libraries/$id")
)
}
private fun SeriesCollection.toOpdsEntry(): OpdsEntryNavigation {
return OpdsEntryNavigation(
private fun SeriesCollection.toOpdsEntry(): OpdsEntryNavigation =
OpdsEntryNavigation(
title = name,
updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
id = id.toString(),
id = id,
content = "",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}collections/$id")
)
}
private fun ReadList.toOpdsEntry(): OpdsEntryNavigation =
OpdsEntryNavigation(
title = name,
updated = lastModifiedDate.atZone(ZoneId.systemDefault()) ?: ZonedDateTime.now(),
id = id,
content = "",
link = OpdsLinkFeedNavigation(OpdsLinkRel.SUBSECTION, "${routeBase}readlists/$id")
)
private fun shouldPrependBookNumbers(userAgent: String) =
userAgent.contains("chunky", ignoreCase = true)

View file

@ -20,6 +20,7 @@ import org.gotson.komga.domain.model.ReadStatus
import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.service.BookLifecycle
import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
@ -30,8 +31,10 @@ import org.gotson.komga.infrastructure.web.setCachePrivate
import org.gotson.komga.interfaces.rest.dto.BookDto
import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto
import org.gotson.komga.interfaces.rest.dto.PageDto
import org.gotson.komga.interfaces.rest.dto.ReadListDto
import org.gotson.komga.interfaces.rest.dto.ReadProgressUpdateDto
import org.gotson.komga.interfaces.rest.dto.restrictUrl
import org.gotson.komga.interfaces.rest.dto.toDto
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
import org.springframework.core.io.FileSystemResource
import org.springframework.data.domain.Page
@ -74,7 +77,8 @@ class BookController(
private val bookRepository: BookRepository,
private val bookMetadataRepository: BookMetadataRepository,
private val mediaRepository: MediaRepository,
private val bookDtoRepository: BookDtoRepository
private val bookDtoRepository: BookDtoRepository,
private val readListRepository: ReadListRepository
) {
@PageableAsQueryParam
@ -195,6 +199,20 @@ class BookController(
}
@GetMapping("api/v1/books/{bookId}/readlists")
fun getAllReadListsByBook(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "bookId") bookId: String
): List<ReadListDto> {
bookRepository.getLibraryId(bookId)?.let {
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return readListRepository.findAllByBook(bookId, principal.user.getAuthorizedLibraryIds(null))
.map { it.toDto() }
}
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
@GetMapping(value = [
"api/v1/books/{bookId}/thumbnail",

View file

@ -76,6 +76,7 @@ class LibraryController(
importComicInfoBook = library.importComicInfoBook,
importComicInfoSeries = library.importComicInfoSeries,
importComicInfoCollection = library.importComicInfoCollection,
importComicInfoReadList = library.importComicInfoReadList,
importEpubBook = library.importEpubBook,
importEpubSeries = library.importEpubSeries,
importLocalArtwork = library.importLocalArtwork,
@ -109,6 +110,7 @@ class LibraryController(
importComicInfoBook = library.importComicInfoBook,
importComicInfoSeries = library.importComicInfoSeries,
importComicInfoCollection = library.importComicInfoCollection,
importComicInfoReadList = library.importComicInfoReadList,
importEpubBook = library.importEpubBook,
importEpubSeries = library.importEpubSeries,
importLocalArtwork = library.importLocalArtwork,
@ -162,6 +164,7 @@ data class LibraryCreationDto(
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 importLocalArtwork: Boolean = true,
@ -176,6 +179,7 @@ data class LibraryDto(
val importComicInfoBook: Boolean,
val importComicInfoSeries: Boolean,
val importComicInfoCollection: Boolean,
val importComicInfoReadList: Boolean,
val importEpubBook: Boolean,
val importEpubSeries: Boolean,
val importLocalArtwork: Boolean,
@ -189,6 +193,7 @@ data class LibraryUpdateDto(
val importComicInfoBook: Boolean,
val importComicInfoSeries: Boolean,
val importComicInfoCollection: Boolean,
val importComicInfoReadList: Boolean,
val importEpubBook: Boolean,
val importEpubSeries: Boolean,
val importLocalArtwork: Boolean,
@ -203,6 +208,7 @@ fun Library.toDto(includeRoot: Boolean) = LibraryDto(
importComicInfoBook = importComicInfoBook,
importComicInfoSeries = importComicInfoSeries,
importComicInfoCollection = importComicInfoCollection,
importComicInfoReadList = importComicInfoReadList,
importEpubBook = importEpubBook,
importEpubSeries = importEpubSeries,
importLocalArtwork = importLocalArtwork,

View file

@ -0,0 +1,173 @@
package org.gotson.komga.interfaces.rest
import io.swagger.v3.oas.annotations.Parameter
import io.swagger.v3.oas.annotations.media.Content
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import mu.KotlinLogging
import org.gotson.komga.domain.model.DuplicateNameException
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.service.ReadListLifecycle
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
import org.gotson.komga.infrastructure.language.toIndexedMap
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
import org.gotson.komga.interfaces.rest.dto.BookDto
import org.gotson.komga.interfaces.rest.dto.ReadListCreationDto
import org.gotson.komga.interfaces.rest.dto.ReadListDto
import org.gotson.komga.interfaces.rest.dto.ReadListUpdateDto
import org.gotson.komga.interfaces.rest.dto.restrictUrl
import org.gotson.komga.interfaces.rest.dto.toDto
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.http.CacheControl
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.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
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.RequestParam
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.util.concurrent.TimeUnit
import javax.validation.Valid
private val logger = KotlinLogging.logger {}
@RestController
@RequestMapping("api/v1/readlists", produces = [MediaType.APPLICATION_JSON_VALUE])
class ReadListController(
private val readListRepository: ReadListRepository,
private val readListLifecycle: ReadListLifecycle,
private val bookDtoRepository: BookDtoRepository
) {
@PageableWithoutSortAsQueryParam
@GetMapping
fun getAll(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "search", required = false) searchTerm: String?,
@RequestParam(name = "library_id", required = false) libraryIds: List<String>?,
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
@Parameter(hidden = true) page: Pageable
): Page<ReadListDto> {
val pageRequest =
if (unpaged) UnpagedSorted(Sort.by(Sort.Order.asc("name")))
else PageRequest.of(
page.pageNumber,
page.pageSize,
Sort.by(Sort.Order.asc("name"))
)
return when {
principal.user.sharedAllLibraries && libraryIds == null -> readListRepository.findAll(searchTerm, pageable = pageRequest)
principal.user.sharedAllLibraries && libraryIds != null -> readListRepository.findAllByLibraries(libraryIds, null, searchTerm, pageable = pageRequest)
!principal.user.sharedAllLibraries && libraryIds != null -> readListRepository.findAllByLibraries(libraryIds, principal.user.sharedLibrariesIds, searchTerm, pageable = pageRequest)
else -> readListRepository.findAllByLibraries(principal.user.sharedLibrariesIds, principal.user.sharedLibrariesIds, searchTerm, pageable = pageRequest)
}.map { it.toDto() }
}
@GetMapping("{id}")
fun getOne(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable id: String
): ReadListDto =
readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))
?.toDto()
?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
@GetMapping(value = ["{id}/thumbnail"], produces = [MediaType.IMAGE_JPEG_VALUE])
fun getReadListThumbnail(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable id: String
): ResponseEntity<ByteArray> {
readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let {
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePrivate())
.body(readListLifecycle.getThumbnailBytes(it))
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@PostMapping
@PreAuthorize("hasRole('$ROLE_ADMIN')")
fun addOne(
@Valid @RequestBody readList: ReadListCreationDto
): ReadListDto =
try {
readListLifecycle.addReadList(ReadList(
name = readList.name,
bookIds = readList.bookIds.toIndexedMap()
)).toDto()
} catch (e: DuplicateNameException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
}
@PatchMapping("{id}")
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun updateOne(
@PathVariable id: String,
@Valid @RequestBody readList: ReadListUpdateDto
) {
readListRepository.findByIdOrNull(id)?.let { existing ->
val updated = existing.copy(
name = readList.name ?: existing.name,
bookIds = readList.bookIds?.toIndexedMap() ?: existing.bookIds
)
try {
readListLifecycle.updateReadList(updated)
} catch (e: DuplicateNameException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@DeleteMapping("{id}")
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteOne(
@PathVariable id: String
) {
readListRepository.findByIdOrNull(id)?.let {
readListLifecycle.deleteReadList(it.id)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@PageableWithoutSortAsQueryParam
@GetMapping("{id}/books")
fun getBooksForReadList(
@PathVariable id: String,
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "unpaged", required = false) unpaged: Boolean = false,
@Parameter(hidden = true) page: Pageable
): Page<BookDto> =
readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList ->
val sort = Sort.by(Sort.Order.asc("readList.number"))
val pageRequest =
if (unpaged) UnpagedSorted(sort)
else PageRequest.of(
page.pageNumber,
page.pageSize,
sort
)
bookDtoRepository.findByReadListId(readList.id, principal.user.id, pageRequest)
.map { it.restrictUrl(!principal.user.roleAdmin) }
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}

View file

@ -10,7 +10,6 @@ import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.SeriesCollection
import org.gotson.komga.domain.persistence.SeriesCollectionRepository
import org.gotson.komga.domain.service.SeriesCollectionLifecycle
import org.gotson.komga.infrastructure.image.MosaicGenerator
import org.gotson.komga.infrastructure.jooq.UnpagedSorted
import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
@ -52,9 +51,7 @@ private val logger = KotlinLogging.logger {}
class SeriesCollectionController(
private val collectionRepository: SeriesCollectionRepository,
private val collectionLifecycle: SeriesCollectionLifecycle,
private val seriesDtoRepository: SeriesDtoRepository,
private val seriesController: SeriesController,
private val mosaicGenerator: MosaicGenerator
private val seriesDtoRepository: SeriesDtoRepository
) {
@PageableWithoutSortAsQueryParam

View file

@ -0,0 +1,10 @@
package org.gotson.komga.interfaces.rest.dto
import org.gotson.komga.infrastructure.validation.Unique
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotEmpty
data class ReadListCreationDto(
@get:NotBlank val name: String,
@get:NotEmpty @get:Unique val bookIds: List<String>
)

View file

@ -0,0 +1,29 @@
package org.gotson.komga.interfaces.rest.dto
import com.fasterxml.jackson.annotation.JsonFormat
import org.gotson.komga.domain.model.ReadList
import java.time.LocalDateTime
data class ReadListDto(
val id: String,
val name: String,
val bookIds: List<String>,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val createdDate: LocalDateTime,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val lastModifiedDate: LocalDateTime,
val filtered: Boolean
)
fun ReadList.toDto() =
ReadListDto(
id = id,
name = name,
bookIds = bookIds.values.toList(),
createdDate = createdDate.toUTC(),
lastModifiedDate = lastModifiedDate.toUTC(),
filtered = filtered
)

View file

@ -0,0 +1,10 @@
package org.gotson.komga.interfaces.rest.dto
import org.gotson.komga.infrastructure.validation.NullOrNotBlank
import org.gotson.komga.infrastructure.validation.NullOrNotEmpty
import org.gotson.komga.infrastructure.validation.Unique
data class ReadListUpdateDto(
@get:NullOrNotBlank val name: String?,
@get:NullOrNotEmpty @get:Unique val bookIds: List<String>?
)

View file

@ -7,6 +7,7 @@ import org.springframework.data.domain.Pageable
interface BookDtoRepository {
fun findAll(search: BookSearchWithReadProgress, userId: String, pageable: Pageable): Page<BookDto>
fun findByReadListId(readListId: String, userId: String, pageable: Pageable): Page<BookDto>
fun findByIdOrNull(bookId: String, userId: String): BookDto?
fun findPreviousInSeries(bookId: String, userId: String): BookDto?
fun findNextInSeries(bookId: String, userId: String): BookDto?

View file

@ -0,0 +1,226 @@
package org.gotson.komga.infrastructure.jooq
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.model.makeSeries
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.infrastructure.language.toIndexedMap
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.data.domain.Pageable
import org.springframework.test.context.junit.jupiter.SpringExtension
import java.time.LocalDateTime
@ExtendWith(SpringExtension::class)
@SpringBootTest
class ReadListDaoTest(
@Autowired private val readListDao: ReadListDao,
@Autowired private val bookRepository: BookRepository,
@Autowired private val seriesRepository: SeriesRepository,
@Autowired private val libraryRepository: LibraryRepository
) {
private val library = makeLibrary()
private val library2 = makeLibrary("library2")
@BeforeAll
fun setup() {
libraryRepository.insert(library)
libraryRepository.insert(library2)
}
@AfterEach
fun deleteSeries() {
readListDao.deleteAll()
bookRepository.deleteAll()
seriesRepository.deleteAll()
}
@AfterAll
fun tearDown() {
libraryRepository.deleteAll()
}
@Test
fun `given read list with books when inserting then it is persisted`() {
// given
val series = makeSeries("Series", library.id)
seriesRepository.insert(series)
val books = (1..10).map { makeBook("Book $it", libraryId = library.id, seriesId = series.id) }
books.forEach { bookRepository.insert(it) }
val readList = ReadList(
name = "MyReadList",
bookIds = books.map { it.id }.toIndexedMap()
)
// when
val now = LocalDateTime.now()
readListDao.insert(readList)
val created = readListDao.findByIdOrNull(readList.id)!!
// then
assertThat(created.name).isEqualTo(readList.name)
assertThat(created.createdDate)
.isEqualTo(created.lastModifiedDate)
.isCloseTo(now, offset)
assertThat(created.bookIds.values).containsExactlyElementsOf(books.map { it.id })
}
@Test
fun `given read list with updated books when updating then it is persisted`() {
// given
val series = makeSeries("Series", library.id)
seriesRepository.insert(series)
val books = (1..10).map { makeBook("Book $it", libraryId = library.id, seriesId = series.id) }
books.forEach { bookRepository.insert(it) }
val readList = ReadList(
name = "MyReadList",
bookIds = books.map { it.id }.toIndexedMap()
)
readListDao.insert(readList)
// when
val updatedReadList = readList.copy(
name = "UpdatedReadList",
bookIds = readList.bookIds.values.take(5).toIndexedMap()
)
val now = LocalDateTime.now()
readListDao.update(updatedReadList)
val updated = readListDao.findByIdOrNull(updatedReadList.id)!!
// then
assertThat(updated.name).isEqualTo(updatedReadList.name)
assertThat(updated.createdDate).isNotEqualTo(updated.lastModifiedDate)
assertThat(updated.lastModifiedDate).isCloseTo(now, offset)
assertThat(updated.bookIds.values)
.hasSize(5)
.containsExactlyElementsOf(books.map { it.id }.take(5))
}
@Test
fun `given read lists with books when removing one book from all then it is removed from all`() {
// given
val series = makeSeries("Series", library.id)
seriesRepository.insert(series)
val books = (1..10).map { makeBook("Book $it", libraryId = library.id, seriesId = series.id) }
books.forEach { bookRepository.insert(it) }
val readList1 = ReadList(
name = "MyReadList",
bookIds = books.map { it.id }.toIndexedMap()
)
readListDao.insert(readList1)
val readList2 = ReadList(
name = "MyReadList",
bookIds = books.map { it.id }.take(5).toIndexedMap()
)
readListDao.insert(readList2)
// when
readListDao.removeBookFromAll(books.first().id)
// then
val rl1 = readListDao.findByIdOrNull(readList1.id)!!
assertThat(rl1.bookIds.values)
.hasSize(9)
.doesNotContain(books.first().id)
val col2 = readListDao.findByIdOrNull(readList2.id)!!
assertThat(col2.bookIds.values)
.hasSize(4)
.doesNotContain(books.first().id)
}
@Test
fun `given read lists spanning different libraries when finding by library then only matching collections are returned`() {
// given
val seriesLibrary1 = makeSeries("Series1", library.id).also { seriesRepository.insert(it) }
val bookLibrary1 = makeBook("Book1", libraryId = library.id, seriesId = seriesLibrary1.id).also { bookRepository.insert(it) }
val seriesLibrary2 = makeSeries("Series2", library2.id).also { seriesRepository.insert(it) }
val bookLibrary2 = makeBook("Book2", libraryId = library2.id, seriesId = seriesLibrary2.id).also { bookRepository.insert(it) }
readListDao.insert(ReadList(
name = "readListLibrary1",
bookIds = listOf(bookLibrary1.id).toIndexedMap()
))
readListDao.insert(ReadList(
name = "readListLibrary2",
bookIds = listOf(bookLibrary2.id).toIndexedMap()
))
readListDao.insert(ReadList(
name = "readListLibraryBoth",
bookIds = listOf(bookLibrary1.id, bookLibrary2.id).toIndexedMap()
))
// when
val foundLibrary1Filtered = readListDao.findAllByLibraries(listOf(library.id), listOf(library.id), pageable = Pageable.unpaged()).content
val foundLibrary1Unfiltered = readListDao.findAllByLibraries(listOf(library.id), null, pageable = Pageable.unpaged()).content
val foundLibrary2Filtered = readListDao.findAllByLibraries(listOf(library2.id), listOf(library2.id), pageable = Pageable.unpaged()).content
val foundLibrary2Unfiltered = readListDao.findAllByLibraries(listOf(library2.id), null, pageable = Pageable.unpaged()).content
val foundBothUnfiltered = readListDao.findAllByLibraries(listOf(library.id, library2.id), null, pageable = Pageable.unpaged()).content
// then
assertThat(foundLibrary1Filtered).hasSize(2)
assertThat(foundLibrary1Filtered.map { it.name }).containsExactly("readListLibrary1", "readListLibraryBoth")
with(foundLibrary1Filtered.find { it.name == "readListLibraryBoth" }!!) {
assertThat(bookIds.values)
.hasSize(1)
.containsExactly(bookLibrary1.id)
assertThat(filtered).isTrue()
}
assertThat(foundLibrary1Unfiltered).hasSize(2)
assertThat(foundLibrary1Unfiltered.map { it.name }).containsExactly("readListLibrary1", "readListLibraryBoth")
with(foundLibrary1Unfiltered.find { it.name == "readListLibraryBoth" }!!) {
assertThat(bookIds.values)
.hasSize(2)
.containsExactly(bookLibrary1.id, bookLibrary2.id)
assertThat(filtered).isFalse()
}
assertThat(foundLibrary2Filtered).hasSize(2)
assertThat(foundLibrary2Filtered.map { it.name }).containsExactly("readListLibrary2", "readListLibraryBoth")
with(foundLibrary2Filtered.find { it.name == "readListLibraryBoth" }!!) {
assertThat(bookIds.values)
.hasSize(1)
.containsExactly(bookLibrary2.id)
assertThat(filtered).isTrue()
}
assertThat(foundLibrary2Unfiltered).hasSize(2)
assertThat(foundLibrary2Unfiltered.map { it.name }).containsExactly("readListLibrary2", "readListLibraryBoth")
with(foundLibrary2Unfiltered.find { it.name == "readListLibraryBoth" }!!) {
assertThat(bookIds.values)
.hasSize(2)
.containsExactly(bookLibrary1.id, bookLibrary2.id)
assertThat(filtered).isFalse()
}
assertThat(foundBothUnfiltered).hasSize(3)
assertThat(foundBothUnfiltered.map { it.name }).containsExactly("readListLibrary1", "readListLibrary2", "readListLibraryBoth")
with(foundBothUnfiltered.find { it.name == "readListLibraryBoth" }!!) {
assertThat(bookIds.values)
.hasSize(2)
.containsExactly(bookLibrary1.id, bookLibrary2.id)
assertThat(filtered).isFalse()
}
}
}

View file

@ -0,0 +1,367 @@
package org.gotson.komga.interfaces.rest
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ReadList
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.model.makeSeries
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.ReadListRepository
import org.gotson.komga.domain.service.LibraryLifecycle
import org.gotson.komga.domain.service.ReadListLifecycle
import org.gotson.komga.domain.service.SeriesLifecycle
import org.gotson.komga.infrastructure.language.toIndexedMap
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.delete
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.patch
import org.springframework.test.web.servlet.post
@ExtendWith(SpringExtension::class)
@SpringBootTest
@AutoConfigureMockMvc(printOnlyOnFailure = false)
class ReadListControllerTest(
@Autowired private val mockMvc: MockMvc,
@Autowired private val readListLifecycle: ReadListLifecycle,
@Autowired private val readListRepository: ReadListRepository,
@Autowired private val libraryLifecycle: LibraryLifecycle,
@Autowired private val libraryRepository: LibraryRepository,
@Autowired private val seriesLifecycle: SeriesLifecycle
) {
private val library1 = makeLibrary("Library1", id = "1")
private val library2 = makeLibrary("Library2", id = "2")
private val seriesLib1 = makeSeries("Series1", library1.id)
private val seriesLib2 = makeSeries("Series2", library2.id)
private lateinit var booksLibrary1: List<Book>
private lateinit var booksLibrary2: List<Book>
private lateinit var rlLib1: ReadList
private lateinit var rlLib2: ReadList
private lateinit var rlLibBoth: ReadList
@BeforeAll
fun setup() {
libraryRepository.insert(library1)
libraryRepository.insert(library2)
seriesLifecycle.createSeries(seriesLib1)
seriesLifecycle.createSeries(seriesLib2)
booksLibrary1 = (1..5).map { makeBook("Book_$it", libraryId = library1.id, seriesId = seriesLib1.id) }
seriesLifecycle.addBooks(seriesLib1, booksLibrary1)
booksLibrary2 = (6..10).map { makeBook("Book_$it", libraryId = library2.id, seriesId = seriesLib2.id) }
seriesLifecycle.addBooks(seriesLib2, booksLibrary2)
}
@AfterAll
fun teardown() {
libraryRepository.findAll().forEach {
libraryLifecycle.deleteLibrary(it)
}
}
@AfterEach
fun clear() {
readListRepository.deleteAll()
}
private fun makeReadLists() {
rlLib1 = readListLifecycle.addReadList(ReadList(
name = "Lib1",
bookIds = booksLibrary1.map { it.id }.toIndexedMap()
))
rlLib2 = readListLifecycle.addReadList(ReadList(
name = "Lib2",
bookIds = booksLibrary2.map { it.id }.toIndexedMap()
))
rlLibBoth = readListLifecycle.addReadList(ReadList(
name = "Lib1+2",
bookIds = (booksLibrary1 + booksLibrary2).map { it.id }.toIndexedMap()
))
}
@Nested
inner class GetAndFilter {
@Test
@WithMockCustomUser
fun `given user with access to all libraries when getting read lists then get all read lists`() {
makeReadLists()
mockMvc.get("/api/v1/readlists")
.andExpect {
status { isOk }
jsonPath("$.totalElements") { value(3) }
jsonPath("$.content[?(@.name == 'Lib1')].filtered") { value(false) }
jsonPath("$.content[?(@.name == 'Lib2')].filtered") { value(false) }
jsonPath("$.content[?(@.name == 'Lib1+2')].filtered") { value(false) }
}
}
@Test
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"])
fun `given user with access to a single library when getting read lists then only get read lists from this library`() {
makeReadLists()
mockMvc.get("/api/v1/readlists")
.andExpect {
status { isOk }
jsonPath("$.totalElements") { value(2) }
jsonPath("$.content[?(@.name == 'Lib1')].filtered") { value(false) }
jsonPath("$.content[?(@.name == 'Lib1+2')].filtered") { value(true) }
}
}
@Test
@WithMockCustomUser
fun `given user with access to all libraries when getting single read list then it is not filtered`() {
makeReadLists()
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}")
.andExpect {
status { isOk }
jsonPath("$.bookIds.length()") { value(10) }
jsonPath("$.filtered") { value(false) }
}
}
@Test
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"])
fun `given user with access to a single library when getting single read list with items from 2 libraries then it is filtered`() {
makeReadLists()
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}")
.andExpect {
status { isOk }
jsonPath("$.bookIds.length()") { value(5) }
jsonPath("$.filtered") { value(true) }
}
}
@Test
@WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"])
fun `given user with access to a single library when getting single read list from another library then return not found`() {
makeReadLists()
mockMvc.get("/api/v1/readlists/${rlLib2.id}")
.andExpect {
status { isNotFound }
}
}
}
@Nested
inner class Creation {
@Test
@WithMockCustomUser
fun `given non-admin user when creating read list then return forbidden`() {
val jsonString = """
{"name":"readlist","bookIds":[3]}
""".trimIndent()
mockMvc.post("/api/v1/readlists") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isForbidden }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given admin user when creating read list then return ok`() {
val jsonString = """
{"name":"readlist","bookIds":["${booksLibrary1.first().id}"]}
""".trimIndent()
mockMvc.post("/api/v1/readlists") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isOk }
jsonPath("$.bookIds.length()") { value(1) }
jsonPath("$.name") { value("readlist") }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given existing read lists when creating read list with existing name then return bad request`() {
makeReadLists()
val jsonString = """
{"name":"Lib1","bookIds":[${booksLibrary1.first().id}]}
""".trimIndent()
mockMvc.post("/api/v1/readlists") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isBadRequest }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given read list with duplicate bookIds when creating read list then return bad request`() {
makeReadLists()
val jsonString = """
{"name":"Lib1","bookIds":[${booksLibrary1.first().id},${booksLibrary1.first().id}]}
""".trimIndent()
mockMvc.post("/api/v1/readlists") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isBadRequest }
}
}
}
@Nested
inner class Update {
@Test
@WithMockCustomUser
fun `given non-admin user when updating read list then return forbidden`() {
val jsonString = """
{"name":"readlist","bookIds":[3]}
""".trimIndent()
mockMvc.patch("/api/v1/readlists/5") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isForbidden }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given admin user when updating read list then return no content`() {
makeReadLists()
val jsonString = """
{"name":"updated","bookIds":["${booksLibrary1.first().id}"]}
""".trimIndent()
mockMvc.patch("/api/v1/readlists/${rlLib1.id}") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent }
}
mockMvc.get("/api/v1/readlists/${rlLib1.id}")
.andExpect {
status { isOk }
jsonPath("$.name") { value("updated") }
jsonPath("$.bookIds.length()") { value(1) }
jsonPath("$.filtered") { value(false) }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given existing read lists when updating read list with existing name then return bad request`() {
makeReadLists()
val jsonString = """{"name":"Lib2"}"""
mockMvc.patch("/api/v1/readlists/${rlLib1.id}") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isBadRequest }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given existing read list when updating read list with duplicate bookIds then return bad request`() {
makeReadLists()
val jsonString = """{"bookIds":[${booksLibrary1.first().id},${booksLibrary1.first().id}]}"""
mockMvc.patch("/api/v1/readlists/${rlLib1.id}") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isBadRequest }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given admin user when updating read list then only updated fields are modified`() {
makeReadLists()
mockMvc.patch("/api/v1/readlists/${rlLib2.id}") {
contentType = MediaType.APPLICATION_JSON
content = """{"name":"newName"}"""
}
mockMvc.get("/api/v1/readlists/${rlLib2.id}")
.andExpect {
status { isOk }
jsonPath("$.name") { value("newName") }
jsonPath("$.bookIds.length()") { value(5) }
}
mockMvc.patch("/api/v1/readlists/${rlLibBoth.id}") {
contentType = MediaType.APPLICATION_JSON
content = """{"bookIds":["${booksLibrary1.first().id}"]}"""
}
mockMvc.get("/api/v1/readlists/${rlLibBoth.id}")
.andExpect {
status { isOk }
jsonPath("$.name") { value("Lib1+2") }
jsonPath("$.bookIds.length()") { value(1) }
}
}
}
@Nested
inner class Delete {
@Test
@WithMockCustomUser
fun `given non-admin user when deleting read list then return forbidden`() {
mockMvc.delete("/api/v1/readlists/5")
.andExpect {
status { isForbidden }
}
}
@Test
@WithMockCustomUser(roles = [ROLE_ADMIN])
fun `given admin user when deleting read list then return no content`() {
makeReadLists()
mockMvc.delete("/api/v1/readlists/${rlLib1.id}")
.andExpect {
status { isNoContent }
}
mockMvc.get("/api/v1/readlists/${rlLib1.id}")
.andExpect {
status { isNotFound }
}
}
}
}