feat(api): manage book read progress per user

ability to mark a book as read, unread, or in progress by storing the last page read

related to #25
This commit is contained in:
Gauthier Roebroeck 2020-06-02 17:25:51 +08:00
parent 59dd3682ef
commit 17c80cd1a1
15 changed files with 568 additions and 28 deletions

View file

@ -0,0 +1,15 @@
create table read_progress
(
book_id bigint not null,
user_id bigint not null,
created_date timestamp not null default now(),
last_modified_date timestamp not null default now(),
page integer not null,
completed boolean not null
);
alter table read_progress
add constraint fk_read_progress_book_book_id foreign key (book_id) references book (id);
alter table read_progress
add constraint fk_read_progress_user_user_id foreign key (user_id) references user (id);

View file

@ -0,0 +1,13 @@
package org.gotson.komga.domain.model
import java.time.LocalDateTime
data class ReadProgress(
val bookId: Long,
val userId: Long,
val page: Int,
val completed: Boolean,
override val createdDate: LocalDateTime = LocalDateTime.now(),
override val lastModifiedDate: LocalDateTime = LocalDateTime.now()
) : Auditable()

View file

@ -0,0 +1,18 @@
package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.ReadProgress
interface ReadProgressRepository {
fun findAll(): Collection<ReadProgress>
fun findByBookIdAndUserId(bookId: Long, userId: Long): ReadProgress?
fun findByUserId(userId: Long): Collection<ReadProgress>
fun save(readProgress: ReadProgress)
fun delete(bookId: Long, userId: Long)
fun deleteByUserId(userId: Long)
fun deleteByBookId(bookId: Long)
fun deleteByBookIds(bookIds: Collection<Long>)
fun deleteAll()
}

View file

@ -4,11 +4,14 @@ import mu.KotlinLogging
import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookPageContent import org.gotson.komga.domain.model.BookPageContent
import org.gotson.komga.domain.model.ImageConversionException import org.gotson.komga.domain.model.ImageConversionException
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaNotReadyException import org.gotson.komga.domain.model.MediaNotReadyException
import org.gotson.komga.domain.model.ReadProgress
import org.gotson.komga.domain.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.infrastructure.image.ImageConverter import org.gotson.komga.infrastructure.image.ImageConverter
import org.gotson.komga.infrastructure.image.ImageType import org.gotson.komga.infrastructure.image.ImageType
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@ -20,6 +23,7 @@ class BookLifecycle(
private val bookRepository: BookRepository, private val bookRepository: BookRepository,
private val mediaRepository: MediaRepository, private val mediaRepository: MediaRepository,
private val bookMetadataRepository: BookMetadataRepository, private val bookMetadataRepository: BookMetadataRepository,
private val readProgressRepository: ReadProgressRepository,
private val bookAnalyzer: BookAnalyzer, private val bookAnalyzer: BookAnalyzer,
private val imageConverter: ImageConverter private val imageConverter: ImageConverter
) { ) {
@ -96,9 +100,27 @@ class BookLifecycle(
fun delete(bookId: Long) { fun delete(bookId: Long) {
logger.info { "Delete book id: $bookId" } logger.info { "Delete book id: $bookId" }
readProgressRepository.deleteByBookId(bookId)
mediaRepository.delete(bookId) mediaRepository.delete(bookId)
bookMetadataRepository.delete(bookId) bookMetadataRepository.delete(bookId)
bookRepository.delete(bookId) bookRepository.delete(bookId)
} }
fun markReadProgress(book: Book, user: KomgaUser, page: Int) {
val media = mediaRepository.findById(book.id)
require(page >= 1 && page <= media.pages.size) { "Page argument ($page) must be within 1 and book page count (${media.pages.size})" }
readProgressRepository.save(ReadProgress(book.id, user.id, page, page == media.pages.size))
}
fun markReadProgressCompleted(book: Book, user: KomgaUser) {
val media = mediaRepository.findById(book.id)
readProgressRepository.save(ReadProgress(book.id, user.id, media.pages.size, true))
}
fun deleteReadProgress(book: Book, user: KomgaUser) {
readProgressRepository.delete(book.id, user.id)
}
} }

View file

@ -4,6 +4,7 @@ import mu.KotlinLogging
import org.gotson.komga.domain.model.KomgaUser import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.UserEmailAlreadyExistsException import org.gotson.komga.domain.model.UserEmailAlreadyExistsException
import org.gotson.komga.domain.persistence.KomgaUserRepository import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.infrastructure.security.KomgaPrincipal
import org.springframework.security.core.session.SessionRegistry import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.core.userdetails.UserDetails
@ -17,6 +18,7 @@ private val logger = KotlinLogging.logger {}
@Service @Service
class KomgaUserLifecycle( class KomgaUserLifecycle(
private val userRepository: KomgaUserRepository, private val userRepository: KomgaUserRepository,
private val readProgressRepository: ReadProgressRepository,
private val passwordEncoder: PasswordEncoder, private val passwordEncoder: PasswordEncoder,
private val sessionRegistry: SessionRegistry private val sessionRegistry: SessionRegistry
@ -52,6 +54,7 @@ class KomgaUserLifecycle(
fun deleteUser(user: KomgaUser) { fun deleteUser(user: KomgaUser) {
logger.info { "Deleting user: $user" } logger.info { "Deleting user: $user" }
readProgressRepository.deleteByUserId(user.id)
userRepository.delete(user) userRepository.delete(user)
expireSessions(user) expireSessions(user)
} }

View file

@ -5,12 +5,14 @@ import org.gotson.komga.interfaces.rest.dto.AuthorDto
import org.gotson.komga.interfaces.rest.dto.BookDto import org.gotson.komga.interfaces.rest.dto.BookDto
import org.gotson.komga.interfaces.rest.dto.BookMetadataDto import org.gotson.komga.interfaces.rest.dto.BookMetadataDto
import org.gotson.komga.interfaces.rest.dto.MediaDto import org.gotson.komga.interfaces.rest.dto.MediaDto
import org.gotson.komga.interfaces.rest.dto.ReadProgressDto
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
import org.gotson.komga.jooq.Tables import org.gotson.komga.jooq.Tables
import org.gotson.komga.jooq.tables.records.BookMetadataAuthorRecord import org.gotson.komga.jooq.tables.records.BookMetadataAuthorRecord
import org.gotson.komga.jooq.tables.records.BookMetadataRecord import org.gotson.komga.jooq.tables.records.BookMetadataRecord
import org.gotson.komga.jooq.tables.records.BookRecord import org.gotson.komga.jooq.tables.records.BookRecord
import org.gotson.komga.jooq.tables.records.MediaRecord import org.gotson.komga.jooq.tables.records.MediaRecord
import org.gotson.komga.jooq.tables.records.ReadProgressRecord
import org.jooq.Condition import org.jooq.Condition
import org.jooq.DSLContext import org.jooq.DSLContext
import org.jooq.Record import org.jooq.Record
@ -31,6 +33,7 @@ class BookDtoDao(
private val b = Tables.BOOK private val b = Tables.BOOK
private val m = Tables.MEDIA private val m = Tables.MEDIA
private val d = Tables.BOOK_METADATA private val d = Tables.BOOK_METADATA
private val r = Tables.READ_PROGRESS
private val a = Tables.BOOK_METADATA_AUTHOR private val a = Tables.BOOK_METADATA_AUTHOR
private val mediaFields = m.fields().filterNot { it.name == m.THUMBNAIL.name }.toTypedArray() private val mediaFields = m.fields().filterNot { it.name == m.THUMBNAIL.name }.toTypedArray()
@ -42,7 +45,7 @@ class BookDtoDao(
"fileSize" to b.FILE_SIZE "fileSize" to b.FILE_SIZE
) )
override fun findAll(search: BookSearch, pageable: Pageable): Page<BookDto> { override fun findAll(search: BookSearch, userId: Long, pageable: Pageable): Page<BookDto> {
val conditions = search.toCondition() val conditions = search.toCondition()
val count = dsl.selectCount() val count = dsl.selectCount()
@ -56,6 +59,7 @@ class BookDtoDao(
val dtos = selectBase() val dtos = selectBase()
.where(conditions) .where(conditions)
.and(readProgressCondition(userId))
.orderBy(orderBy) .orderBy(orderBy)
.limit(pageable.pageSize) .limit(pageable.pageSize)
.offset(pageable.offset) .offset(pageable.offset)
@ -68,18 +72,20 @@ class BookDtoDao(
) )
} }
override fun findByIdOrNull(bookId: Long): BookDto? = override fun findByIdOrNull(bookId: Long, userId: Long): BookDto? =
selectBase() selectBase()
.where(b.ID.eq(bookId)) .where(b.ID.eq(bookId))
.and(readProgressCondition(userId))
.fetchAndMap() .fetchAndMap()
.firstOrNull() .firstOrNull()
override fun findPreviousInSeries(bookId: Long): BookDto? = findSibling(bookId, next = false) override fun findPreviousInSeries(bookId: Long, userId: Long): BookDto? = findSibling(bookId, userId, next = false)
override fun findNextInSeries(bookId: Long): BookDto? = findSibling(bookId, next = true) override fun findNextInSeries(bookId: Long, userId: Long): BookDto? = findSibling(bookId, userId, next = true)
private fun readProgressCondition(userId: Long): Condition = r.USER_ID.eq(userId).or(r.USER_ID.isNull)
private fun findSibling(bookId: Long, next: Boolean): BookDto? { private fun findSibling(bookId: Long, userId: Long, next: Boolean): BookDto? {
val record = dsl.select(b.SERIES_ID, d.NUMBER_SORT) val record = dsl.select(b.SERIES_ID, d.NUMBER_SORT)
.from(b) .from(b)
.leftJoin(d).on(b.ID.eq(d.BOOK_ID)) .leftJoin(d).on(b.ID.eq(d.BOOK_ID))
@ -90,6 +96,7 @@ class BookDtoDao(
return selectBase() return selectBase()
.where(b.SERIES_ID.eq(seriesId)) .where(b.SERIES_ID.eq(seriesId))
.and(readProgressCondition(userId))
.orderBy(d.NUMBER_SORT.let { if (next) it.asc() else it.desc() }) .orderBy(d.NUMBER_SORT.let { if (next) it.asc() else it.desc() })
.seek(numberSort) .seek(numberSort)
.limit(1) .limit(1)
@ -102,20 +109,23 @@ class BookDtoDao(
*b.fields(), *b.fields(),
*mediaFields, *mediaFields,
*d.fields(), *d.fields(),
*a.fields() *a.fields(),
*r.fields()
).from(b) ).from(b)
.leftJoin(m).on(b.ID.eq(m.BOOK_ID)) .leftJoin(m).on(b.ID.eq(m.BOOK_ID))
.leftJoin(d).on(b.ID.eq(d.BOOK_ID)) .leftJoin(d).on(b.ID.eq(d.BOOK_ID))
.leftJoin(a).on(d.BOOK_ID.eq(a.BOOK_ID)) .leftJoin(a).on(d.BOOK_ID.eq(a.BOOK_ID))
.leftJoin(r).on(b.ID.eq(r.BOOK_ID))
private fun ResultQuery<Record>.fetchAndMap() = private fun ResultQuery<Record>.fetchAndMap() =
fetchGroups( fetchGroups(
{ it.into(*b.fields(), *mediaFields, *d.fields()) }, { it.into(a) } { it.into(*b.fields(), *mediaFields, *d.fields(), *r.fields()) }, { it.into(a) }
).map { (r, ar) -> ).map { (rec, ar) ->
val br = r.into(b) val br = rec.into(b)
val mr = r.into(m) val mr = rec.into(m)
val dr = r.into(d) val dr = rec.into(d)
br.toDto(mr.toDto(), dr.toDto(ar)) val rr = rec.into(r)
br.toDto(mr.toDto(), dr.toDto(ar), if (rr.userId != null) rr.toDto() else null)
} }
private fun BookSearch.toCondition(): Condition { private fun BookSearch.toCondition(): Condition {
@ -129,7 +139,7 @@ class BookDtoDao(
return c return c
} }
private fun BookRecord.toDto(media: MediaDto, metadata: BookMetadataDto) = private fun BookRecord.toDto(media: MediaDto, metadata: BookMetadataDto, readProgress: ReadProgressDto?) =
BookDto( BookDto(
id = id, id = id,
seriesId = seriesId, seriesId = seriesId,
@ -142,7 +152,8 @@ class BookDtoDao(
fileLastModified = fileLastModified.toUTC(), fileLastModified = fileLastModified.toUTC(),
sizeBytes = fileSize, sizeBytes = fileSize,
media = media, media = media,
metadata = metadata metadata = metadata,
readProgress = readProgress
) )
private fun MediaRecord.toDto() = private fun MediaRecord.toDto() =
@ -174,4 +185,12 @@ class BookDtoDao(
authors = ar.filter { it.name != null }.map { AuthorDto(it.name, it.role) }, authors = ar.filter { it.name != null }.map { AuthorDto(it.name, it.role) },
authorsLock = authorsLock authorsLock = authorsLock
) )
private fun ReadProgressRecord.toDto() =
ReadProgressDto(
page = page,
completed = completed,
created = createdDate.toUTC(),
lastModified = lastModifiedDate.toUTC()
)
} }

View file

@ -0,0 +1,91 @@
package org.gotson.komga.infrastructure.jooq
import org.gotson.komga.domain.model.ReadProgress
import org.gotson.komga.domain.persistence.ReadProgressRepository
import org.gotson.komga.jooq.Tables
import org.gotson.komga.jooq.tables.records.ReadProgressRecord
import org.jooq.DSLContext
import org.springframework.stereotype.Component
import java.time.LocalDateTime
@Component
class ReadProgressDao(
private val dsl: DSLContext
) : ReadProgressRepository {
private val r = Tables.READ_PROGRESS
override fun findAll(): Collection<ReadProgress> =
dsl.selectFrom(r)
.fetchInto(r)
.map { it.toDomain() }
override fun findByBookIdAndUserId(bookId: Long, userId: Long): ReadProgress? =
dsl.selectFrom(r)
.where(r.BOOK_ID.eq(bookId).and(r.USER_ID.eq(userId)))
.fetchOneInto(r)
?.toDomain()
override fun findByUserId(userId: Long): Collection<ReadProgress> =
dsl.selectFrom(r)
.where(r.USER_ID.eq(userId))
.fetchInto(r)
.map { it.toDomain() }
override fun save(readProgress: ReadProgress) {
dsl.mergeInto(r)
.using(dsl.selectOne())
.on(r.BOOK_ID.eq(readProgress.bookId).and(r.USER_ID.eq(readProgress.userId)))
.whenMatchedThenUpdate()
.set(r.PAGE, readProgress.page)
.set(r.COMPLETED, readProgress.completed)
.set(r.LAST_MODIFIED_DATE, LocalDateTime.now())
.whenNotMatchedThenInsert()
.set(r.BOOK_ID, readProgress.bookId)
.set(r.USER_ID, readProgress.userId)
.set(r.PAGE, readProgress.page)
.set(r.COMPLETED, readProgress.completed)
.execute()
}
override fun delete(bookId: Long, userId: Long) {
dsl.deleteFrom(r)
.where(r.BOOK_ID.eq(bookId).and(r.USER_ID.eq(userId)))
.execute()
}
override fun deleteByUserId(userId: Long) {
dsl.deleteFrom(r)
.where(r.USER_ID.eq(userId))
.execute()
}
override fun deleteByBookId(bookId: Long) {
dsl.deleteFrom(r)
.where(r.BOOK_ID.eq(bookId))
.execute()
}
override fun deleteByBookIds(bookIds: Collection<Long>) {
dsl.deleteFrom(r)
.where(r.BOOK_ID.`in`(bookIds))
.execute()
}
override fun deleteAll() {
dsl.deleteFrom(r).execute()
}
private fun ReadProgressRecord.toDomain() =
ReadProgress(
bookId = bookId,
userId = userId,
page = page,
completed = completed,
createdDate = createdDate,
lastModifiedDate = lastModifiedDate
)
}

View file

@ -23,6 +23,7 @@ import org.gotson.komga.infrastructure.swagger.PageableWithoutSortAsQueryParam
import org.gotson.komga.interfaces.rest.dto.BookDto import org.gotson.komga.interfaces.rest.dto.BookDto
import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto import org.gotson.komga.interfaces.rest.dto.BookMetadataUpdateDto
import org.gotson.komga.interfaces.rest.dto.PageDto import org.gotson.komga.interfaces.rest.dto.PageDto
import org.gotson.komga.interfaces.rest.dto.ReadProgressUpdateDto
import org.gotson.komga.interfaces.rest.dto.restrictUrl import org.gotson.komga.interfaces.rest.dto.restrictUrl
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
import org.springframework.core.io.FileSystemResource import org.springframework.core.io.FileSystemResource
@ -38,6 +39,7 @@ import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal 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.GetMapping
import org.springframework.web.bind.annotation.PatchMapping import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PathVariable
@ -90,7 +92,7 @@ class BookController(
mediaStatus = mediaStatus ?: emptyList() mediaStatus = mediaStatus ?: emptyList()
) )
return bookDtoRepository.findAll(bookSearch, pageRequest) return bookDtoRepository.findAll(bookSearch, principal.user.id, pageRequest)
.map { it.restrictUrl(!principal.user.roleAdmin) } .map { it.restrictUrl(!principal.user.roleAdmin) }
} }
@ -114,6 +116,7 @@ class BookController(
BookSearch( BookSearch(
libraryIds = libraryIds libraryIds = libraryIds
), ),
principal.user.id,
pageRequest pageRequest
).map { it.restrictUrl(!principal.user.roleAdmin) } ).map { it.restrictUrl(!principal.user.roleAdmin) }
} }
@ -124,7 +127,7 @@ class BookController(
@AuthenticationPrincipal principal: KomgaPrincipal, @AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable bookId: Long @PathVariable bookId: Long
): BookDto = ): BookDto =
bookDtoRepository.findByIdOrNull(bookId)?.let { bookDtoRepository.findByIdOrNull(bookId, principal.user.id)?.let {
if (!principal.user.canAccessLibrary(it.libraryId)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessLibrary(it.libraryId)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
it.restrictUrl(!principal.user.roleAdmin) it.restrictUrl(!principal.user.roleAdmin)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@ -138,7 +141,7 @@ class BookController(
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return bookDtoRepository.findPreviousInSeries(bookId) return bookDtoRepository.findPreviousInSeries(bookId, principal.user.id)
?.restrictUrl(!principal.user.roleAdmin) ?.restrictUrl(!principal.user.roleAdmin)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} }
@ -152,7 +155,7 @@ class BookController(
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return bookDtoRepository.findNextInSeries(bookId) return bookDtoRepository.findNextInSeries(bookId, principal.user.id)
?.restrictUrl(!principal.user.roleAdmin) ?.restrictUrl(!principal.user.roleAdmin)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
} }
@ -342,7 +345,8 @@ class BookController(
fun updateMetadata( fun updateMetadata(
@PathVariable bookId: Long, @PathVariable bookId: Long,
@Parameter(description = "Metadata fields to update. Set a field to null to unset the metadata. You can omit fields you don't want to update.") @Parameter(description = "Metadata fields to update. Set a field to null to unset the metadata. You can omit fields you don't want to update.")
@Valid @RequestBody newMetadata: BookMetadataUpdateDto @Valid @RequestBody newMetadata: BookMetadataUpdateDto,
@AuthenticationPrincipal principal: KomgaPrincipal
): BookDto = ): BookDto =
bookMetadataRepository.findByIdOrNull(bookId)?.let { existing -> bookMetadataRepository.findByIdOrNull(bookId)?.let { existing ->
val updated = with(newMetadata) { val updated = with(newMetadata) {
@ -370,9 +374,45 @@ class BookController(
) )
} }
bookMetadataRepository.update(updated) bookMetadataRepository.update(updated)
bookDtoRepository.findByIdOrNull(bookId) bookDtoRepository.findByIdOrNull(bookId, principal.user.id)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@PatchMapping("api/v1/books/{bookId}/read-progress")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun markReadProgress(
@PathVariable bookId: Long,
@Parameter(description = "page can be omitted if completed is set to true. completed can be omitted, and will be set accordingly depending on the page passed and the total number of pages in the book.")
@Valid @RequestBody readProgress: ReadProgressUpdateDto,
@AuthenticationPrincipal principal: KomgaPrincipal
) {
bookRepository.findByIdOrNull(bookId)?.let { book ->
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
try {
if (readProgress.completed != null && readProgress.completed)
bookLifecycle.markReadProgressCompleted(book, principal.user)
else
bookLifecycle.markReadProgress(book, principal.user, readProgress.page!!)
} catch (e: IllegalArgumentException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message)
}
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@DeleteMapping("api/v1/books/{bookId}/read-progress")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun deleteReadProgress(
@PathVariable bookId: Long,
@AuthenticationPrincipal principal: KomgaPrincipal
) {
bookRepository.findByIdOrNull(bookId)?.let { book ->
if (!principal.user.canAccessBook(book)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED)
bookLifecycle.deleteReadProgress(book, principal.user)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
private fun ResponseEntity.BodyBuilder.setCachePrivate() = private fun ResponseEntity.BodyBuilder.setCachePrivate() =
this.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS) this.cacheControl(CacheControl.maxAge(0, TimeUnit.SECONDS)
.cachePrivate() .cachePrivate()

View file

@ -196,6 +196,7 @@ class SeriesController(
seriesIds = listOf(seriesId), seriesIds = listOf(seriesId),
mediaStatus = mediaStatus ?: emptyList() mediaStatus = mediaStatus ?: emptyList()
), ),
principal.user.id,
pageRequest pageRequest
).map { it.restrictUrl(!principal.user.roleAdmin) } ).map { it.restrictUrl(!principal.user.roleAdmin) }
} }

View file

@ -22,7 +22,8 @@ data class BookDto(
val sizeBytes: Long, val sizeBytes: Long,
val size: String = BinaryByteUnit.format(sizeBytes), val size: String = BinaryByteUnit.format(sizeBytes),
val media: MediaDto, val media: MediaDto,
val metadata: BookMetadataDto val metadata: BookMetadataDto,
val readProgress: ReadProgressDto? = null
) )
fun BookDto.restrictUrl(restrict: Boolean) = fun BookDto.restrictUrl(restrict: Boolean) =
@ -62,3 +63,12 @@ data class AuthorDto(
val role: String val role: String
) )
data class ReadProgressDto(
val page: Int,
val completed: Boolean,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val created: LocalDateTime,
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val lastModified: LocalDateTime
)

View file

@ -0,0 +1,29 @@
package org.gotson.komga.interfaces.rest.dto
import javax.validation.Constraint
import javax.validation.ConstraintValidator
import javax.validation.ConstraintValidatorContext
import javax.validation.constraints.Positive
import kotlin.reflect.KClass
@ReadProgressUpdateDtoConstraint
data class ReadProgressUpdateDto(
@get:Positive val page: Int?,
val completed: Boolean?
)
@Constraint(validatedBy = [ReadProgressUpdateDtoValidator::class])
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class ReadProgressUpdateDtoConstraint(
val message: String = "page must be specified if completed is false or null",
val groups: Array<KClass<out Any>> = [],
val payload: Array<KClass<out Any>> = []
)
class ReadProgressUpdateDtoValidator : ConstraintValidator<ReadProgressUpdateDtoConstraint, ReadProgressUpdateDto> {
override fun isValid(value: ReadProgressUpdateDto?, context: ConstraintValidatorContext?): Boolean =
value != null && (
value.page != null || (value.completed != null && value.completed)
)
}

View file

@ -6,8 +6,8 @@ import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
interface BookDtoRepository { interface BookDtoRepository {
fun findAll(search: BookSearch, pageable: Pageable): Page<BookDto> fun findAll(search: BookSearch, userId: Long, pageable: Pageable): Page<BookDto>
fun findByIdOrNull(bookId: Long): BookDto? fun findByIdOrNull(bookId: Long, userId: Long): BookDto?
fun findPreviousInSeries(bookId: Long): BookDto? fun findPreviousInSeries(bookId: Long, userId: Long): BookDto?
fun findNextInSeries(bookId: Long): BookDto? fun findNextInSeries(bookId: Long, userId: Long): BookDto?
} }

View file

@ -0,0 +1,122 @@
package org.gotson.komga.infrastructure.jooq
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.ReadProgress
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.KomgaUserRepository
import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.SeriesRepository
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.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit.jupiter.SpringExtension
import java.time.LocalDateTime
@ExtendWith(SpringExtension::class)
@SpringBootTest
@AutoConfigureTestDatabase
class ReadProgressDaoTest(
@Autowired private val readProgressDao: ReadProgressDao,
@Autowired private val userRepository: KomgaUserRepository,
@Autowired private val bookRepository: BookRepository,
@Autowired private val seriesRepository: SeriesRepository,
@Autowired private val libraryRepository: LibraryRepository
) {
private var library = makeLibrary()
private var series = makeSeries("Series")
private var user1 = KomgaUser("user1@example.org", "", false)
private var user2 = KomgaUser("user2@example.org", "", false)
private var book1 = makeBook("Book1")
private var book2 = makeBook("Book2")
@BeforeAll
fun setup() {
library = libraryRepository.insert(library)
series = seriesRepository.insert(series.copy(libraryId = library.id))
user1 = userRepository.save(user1)
user2 = userRepository.save(user2)
book1 = bookRepository.insert(book1.copy(libraryId = library.id, seriesId = series.id))
book2 = bookRepository.insert(book2.copy(libraryId = library.id, seriesId = series.id))
}
@AfterEach
fun deleteReadProgress() {
readProgressDao.deleteAll()
}
@AfterAll
fun tearDown() {
userRepository.deleteAll()
bookRepository.deleteAll()
seriesRepository.deleteAll()
libraryRepository.deleteAll()
}
@Test
fun `given book without user progress when saving progress then progress is saved`() {
val now = LocalDateTime.now()
readProgressDao.save(ReadProgress(
book1.id,
user1.id,
5,
false
))
val readProgressList = readProgressDao.findByUserId(user1.id)
assertThat(readProgressList).hasSize(1)
with(readProgressList.first()) {
assertThat(page).isEqualTo(5)
assertThat(completed).isEqualTo(false)
assertThat(bookId).isEqualTo(book1.id)
assertThat(createdDate)
.isAfterOrEqualTo(now)
.isEqualTo(lastModifiedDate)
}
}
@Test
fun `given book with user progress when saving progress then progress is updated`() {
readProgressDao.save(ReadProgress(
book1.id,
user1.id,
5,
false
))
Thread.sleep(5)
val modificationDate = LocalDateTime.now()
readProgressDao.save(ReadProgress(
book1.id,
user1.id,
10,
true
))
val readProgressList = readProgressDao.findByUserId(user1.id)
assertThat(readProgressList).hasSize(1)
with(readProgressList.first()) {
assertThat(page).isEqualTo(10)
assertThat(completed).isEqualTo(true)
assertThat(bookId).isEqualTo(book1.id)
assertThat(createdDate)
.isBefore(modificationDate)
.isNotEqualTo(lastModifiedDate)
assertThat(lastModifiedDate).isAfterOrEqualTo(modificationDate)
}
}
}

View file

@ -6,17 +6,21 @@ import org.gotson.komga.domain.model.Author
import org.gotson.komga.domain.model.BookMetadata import org.gotson.komga.domain.model.BookMetadata
import org.gotson.komga.domain.model.BookPage import org.gotson.komga.domain.model.BookPage
import org.gotson.komga.domain.model.BookSearch import org.gotson.komga.domain.model.BookSearch
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.makeBook import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeLibrary import org.gotson.komga.domain.model.makeLibrary
import org.gotson.komga.domain.model.makeSeries import org.gotson.komga.domain.model.makeSeries
import org.gotson.komga.domain.persistence.BookMetadataRepository import org.gotson.komga.domain.persistence.BookMetadataRepository
import org.gotson.komga.domain.persistence.BookRepository import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.KomgaUserRepository
import org.gotson.komga.domain.persistence.LibraryRepository import org.gotson.komga.domain.persistence.LibraryRepository
import org.gotson.komga.domain.persistence.MediaRepository import org.gotson.komga.domain.persistence.MediaRepository
import org.gotson.komga.domain.persistence.SeriesRepository import org.gotson.komga.domain.persistence.SeriesRepository
import org.gotson.komga.domain.service.KomgaUserLifecycle
import org.gotson.komga.domain.service.LibraryLifecycle import org.gotson.komga.domain.service.LibraryLifecycle
import org.gotson.komga.domain.service.SeriesLifecycle import org.gotson.komga.domain.service.SeriesLifecycle
import org.hamcrest.core.IsNull
import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeAll
@ -36,6 +40,7 @@ import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.MockMvcResultMatchersDsl import org.springframework.test.web.servlet.MockMvcResultMatchersDsl
import org.springframework.test.web.servlet.delete
import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.patch import org.springframework.test.web.servlet.patch
import java.time.LocalDate import java.time.LocalDate
@ -54,6 +59,8 @@ class BookControllerTest(
@Autowired private val libraryRepository: LibraryRepository, @Autowired private val libraryRepository: LibraryRepository,
@Autowired private val libraryLifecycle: LibraryLifecycle, @Autowired private val libraryLifecycle: LibraryLifecycle,
@Autowired private val bookRepository: BookRepository, @Autowired private val bookRepository: BookRepository,
@Autowired private val userRepository: KomgaUserRepository,
@Autowired private val userLifecycle: KomgaUserLifecycle,
@Autowired private val mockMvc: MockMvc @Autowired private val mockMvc: MockMvc
) { ) {
@ -70,11 +77,15 @@ class BookControllerTest(
fun `setup library`() { fun `setup library`() {
jdbcTemplate.execute("ALTER SEQUENCE hibernate_sequence RESTART WITH 1") jdbcTemplate.execute("ALTER SEQUENCE hibernate_sequence RESTART WITH 1")
library = libraryRepository.insert(library) library = libraryRepository.insert(library) // id = 1
userRepository.save(KomgaUser("user@example.org", "", false)) // id = 2
} }
@AfterAll @AfterAll
fun `teardown library`() { fun `teardown library`() {
userRepository.findAll().forEach {
userLifecycle.deleteUser(it)
}
libraryRepository.findAll().forEach { libraryRepository.findAll().forEach {
libraryLifecycle.deleteLibrary(it) libraryLifecycle.deleteLibrary(it)
} }
@ -737,4 +748,148 @@ class BookControllerTest(
} }
} }
} }
@Nested
inner class ReadProgress {
@ParameterizedTest
@ValueSource(strings = [
"""{"completed": false}""",
"""{}""",
"""{"page":0}"""
])
@WithMockCustomUser
fun `given invalid payload when marking book in progress then validation error is returned`(jsonString: String) {
mockMvc.patch("/api/v1/books/1/read-progress") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isBadRequest }
}
}
@Test
@WithMockCustomUser(id = 2)
fun `given user when marking book in progress with page read then progress is marked accordingly`() {
makeSeries(name = "series", libraryId = library.id).let { series ->
seriesLifecycle.createSeries(series).also { created ->
val books = listOf(makeBook("1.cbr", libraryId = library.id))
seriesLifecycle.addBooks(created, books)
}
}
val book = bookRepository.findAll().first()
mediaRepository.findById(book.id).let {
mediaRepository.update(it.copy(
status = Media.Status.READY,
pages = (1..10).map { BookPage("$it", "image/jpeg") }
))
}
val jsonString = """
{
"page": 5
}
""".trimIndent()
mockMvc.patch("/api/v1/books/${book.id}/read-progress") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent }
}
mockMvc.get("/api/v1/books/${book.id}")
.andExpect {
status { isOk }
jsonPath("$.readProgress.page") { value(5) }
jsonPath("$.readProgress.completed") { value(false) }
}
}
@Test
@WithMockCustomUser(id = 2)
fun `given user when marking book completed then progress is marked accordingly`() {
makeSeries(name = "series", libraryId = library.id).let { series ->
seriesLifecycle.createSeries(series).also { created ->
val books = listOf(makeBook("1.cbr", libraryId = library.id))
seriesLifecycle.addBooks(created, books)
}
}
val book = bookRepository.findAll().first()
mediaRepository.findById(book.id).let {
mediaRepository.update(it.copy(
status = Media.Status.READY,
pages = (1..10).map { BookPage("$it", "image/jpeg") }
))
}
val jsonString = """
{
"completed": true
}
""".trimIndent()
mockMvc.patch("/api/v1/books/${book.id}/read-progress") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent }
}
mockMvc.get("/api/v1/books/${book.id}")
.andExpect {
status { isOk }
jsonPath("$.readProgress.page") { value(10) }
jsonPath("$.readProgress.completed") { value(true) }
}
}
@Test
@WithMockCustomUser(id = 2)
fun `given user when deleting read progress then progress is removed`() {
makeSeries(name = "series", libraryId = library.id).let { series ->
seriesLifecycle.createSeries(series).also { created ->
val books = listOf(makeBook("1.cbr", libraryId = library.id))
seriesLifecycle.addBooks(created, books)
}
}
val book = bookRepository.findAll().first()
mediaRepository.findById(book.id).let { media ->
mediaRepository.update(media.copy(
status = Media.Status.READY,
pages = (1..10).map { BookPage("$it", "image/jpeg") }
))
}
val jsonString = """
{
"page": 5,
"completed": false
}
""".trimIndent()
mockMvc.patch("/api/v1/books/${book.id}/read-progress") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent }
}
mockMvc.delete("/api/v1/books/${book.id}/read-progress") {
contentType = MediaType.APPLICATION_JSON
}.andExpect {
status { isNoContent }
}
mockMvc.get("/api/v1/books/${book.id}")
.andExpect {
status { isOk }
jsonPath("$.readProgress") { value(IsNull.nullValue()) }
}
}
}
} }

View file

@ -15,7 +15,8 @@ annotation class WithMockCustomUser(
val email: String = "user@example.org", val email: String = "user@example.org",
val roles: Array<String> = [], val roles: Array<String> = [],
val sharedAllLibraries: Boolean = true, val sharedAllLibraries: Boolean = true,
val sharedLibraries: LongArray = [] val sharedLibraries: LongArray = [],
val id: Long = 0
) )
class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<WithMockCustomUser> { class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<WithMockCustomUser> {
@ -28,7 +29,8 @@ class WithMockCustomUserSecurityContextFactory : WithSecurityContextFactory<With
password = "", password = "",
roleAdmin = customUser.roles.contains("ADMIN"), roleAdmin = customUser.roles.contains("ADMIN"),
sharedAllLibraries = customUser.sharedAllLibraries, sharedAllLibraries = customUser.sharedAllLibraries,
sharedLibrariesIds = customUser.sharedLibraries.toSet() sharedLibrariesIds = customUser.sharedLibraries.toSet(),
id = customUser.id
) )
) )
val auth = UsernamePasswordAuthenticationToken(principal, "", principal.authorities) val auth = UsernamePasswordAuthenticationToken(principal, "", principal.authorities)