feat(api): read progress v2 for Tachiyomi

uses numberSort instead of index of book in series
This commit is contained in:
Gauthier Roebroeck 2021-09-03 19:38:54 +08:00
parent 202abe1f48
commit 9d92b2594d
8 changed files with 174 additions and 71 deletions

View file

@ -17,6 +17,7 @@ import org.gotson.komga.jooq.tables.records.BookMetadataRecord
import org.gotson.komga.jooq.tables.records.BookRecord
import org.gotson.komga.jooq.tables.records.MediaRecord
import org.gotson.komga.jooq.tables.records.ReadProgressRecord
import org.jooq.AggregateFunction
import org.jooq.Condition
import org.jooq.DSLContext
import org.jooq.Record
@ -31,6 +32,7 @@ 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.math.BigDecimal
import java.net.URL
@Component
@ -48,6 +50,10 @@ class BookDtoDao(
private val rlb = Tables.READLIST_BOOK
private val bt = Tables.BOOK_METADATA_TAG
private val countUnread: AggregateFunction<BigDecimal> = DSL.sum(DSL.`when`(r.COMPLETED.isNull, 1).otherwise(0))
private val countRead: AggregateFunction<BigDecimal> = DSL.sum(DSL.`when`(r.COMPLETED.isTrue, 1).otherwise(0))
private val countInProgress: AggregateFunction<BigDecimal> = DSL.sum(DSL.`when`(r.COMPLETED.isFalse, 1).otherwise(0))
private val sorts = mapOf(
"name" to b.NAME.collate(SqliteUdfDataSource.collationUnicode3),
"created" to b.CREATED_DATE,
@ -169,9 +175,9 @@ class BookDtoDao(
.leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(readProgressCondition(userId))
.apply { filterOnLibraryIds?.let { where(s.LIBRARY_ID.`in`(it)) } }
.groupBy(s.ID)
.having(SeriesDtoDao.countUnread.ge(inline(1.toBigDecimal())))
.and(SeriesDtoDao.countRead.ge(inline(1.toBigDecimal())))
.and(SeriesDtoDao.countInProgress.eq(inline(0.toBigDecimal())))
.having(countUnread.ge(inline(1.toBigDecimal())))
.and(countRead.ge(inline(1.toBigDecimal())))
.and(countInProgress.eq(inline(0.toBigDecimal())))
.orderBy(DSL.max(r.LAST_MODIFIED_DATE).desc())
.fetchInto(String::class.java)

View file

@ -1,14 +1,17 @@
package org.gotson.komga.infrastructure.jooq
import org.gotson.komga.interfaces.rest.dto.TachiyomiReadProgressDto
import org.gotson.komga.interfaces.rest.dto.TachiyomiReadProgressV2Dto
import org.gotson.komga.interfaces.rest.persistence.ReadProgressDtoRepository
import org.gotson.komga.jooq.Tables
import org.jooq.AggregateFunction
import org.jooq.Condition
import org.jooq.DSLContext
import org.jooq.Record
import org.jooq.Record2
import org.jooq.impl.DSL
import org.jooq.impl.DSL.rowNumber
import org.springframework.stereotype.Component
import java.math.BigDecimal
@Component
class ReadProgressDtoDao(
@ -20,6 +23,10 @@ class ReadProgressDtoDao(
private val d = Tables.BOOK_METADATA
private val r = Tables.READ_PROGRESS
private val countUnread: AggregateFunction<BigDecimal> = DSL.sum(DSL.`when`(r.COMPLETED.isNull, 1).otherwise(0))
private val countRead: AggregateFunction<BigDecimal> = DSL.sum(DSL.`when`(r.COMPLETED.isTrue, 1).otherwise(0))
private val countInProgress: AggregateFunction<BigDecimal> = DSL.sum(DSL.`when`(r.COMPLETED.isFalse, 1).otherwise(0))
override fun findProgressBySeries(seriesId: String, userId: String): TachiyomiReadProgressDto {
val indexedReadProgress = dsl.select(
rowNumber().over().orderBy(d.NUMBER_SORT),
@ -33,19 +40,46 @@ class ReadProgressDtoDao(
.fetch()
.toList()
val booksCountRecord = dsl
.select(SeriesDtoDao.countUnread.`as`(BOOKS_UNREAD_COUNT))
.select(SeriesDtoDao.countRead.`as`(BOOKS_READ_COUNT))
.select(SeriesDtoDao.countInProgress.`as`(BOOKS_IN_PROGRESS_COUNT))
val booksCount = getSeriesBooksCount(seriesId, userId)
return booksCountToDto(booksCount, indexedReadProgress.lastRead() ?: 0)
}
override fun findProgressV2BySeries(seriesId: String, userId: String): TachiyomiReadProgressV2Dto {
val numberSortReadProgress = dsl.select(
d.NUMBER_SORT,
r.COMPLETED,
)
.from(b)
.leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(readProgressCondition(userId))
.leftJoin(d).on(b.ID.eq(d.BOOK_ID))
.where(b.SERIES_ID.eq(seriesId))
.orderBy(d.NUMBER_SORT)
.fetch()
.first()
.toList()
return booksCountToDto(booksCountRecord, indexedReadProgress)
val booksCount = getSeriesBooksCount(seriesId, userId)
return booksCountToDtoV2(booksCount, numberSortReadProgress.lastRead() ?: 0F)
}
private fun getSeriesBooksCount(seriesId: String, userId: String) = dsl
.select(countUnread.`as`(BOOKS_UNREAD_COUNT))
.select(countRead.`as`(BOOKS_READ_COUNT))
.select(countInProgress.`as`(BOOKS_IN_PROGRESS_COUNT))
.from(b)
.leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(readProgressCondition(userId))
.where(b.SERIES_ID.eq(seriesId))
.fetch()
.first()
.map {
BooksCount(
unreadCount = it.get(BOOKS_UNREAD_COUNT, Int::class.java),
readCount = it.get(BOOKS_READ_COUNT, Int::class.java),
inProgressCount = it.get(BOOKS_IN_PROGRESS_COUNT, Int::class.java),
)
}
override fun findProgressByReadList(readListId: String, userId: String): TachiyomiReadProgressDto {
val indexedReadProgress = dsl.select(
rowNumber().over().orderBy(rlb.NUMBER),
@ -60,9 +94,9 @@ class ReadProgressDtoDao(
.toList()
val booksCountRecord = dsl
.select(SeriesDtoDao.countUnread.`as`(BOOKS_UNREAD_COUNT))
.select(SeriesDtoDao.countRead.`as`(BOOKS_READ_COUNT))
.select(SeriesDtoDao.countInProgress.`as`(BOOKS_IN_PROGRESS_COUNT))
.select(countUnread.`as`(BOOKS_UNREAD_COUNT))
.select(countRead.`as`(BOOKS_READ_COUNT))
.select(countInProgress.`as`(BOOKS_IN_PROGRESS_COUNT))
.from(b)
.leftJoin(r).on(b.ID.eq(r.BOOK_ID)).and(readProgressCondition(userId))
.leftJoin(rlb).on(b.ID.eq(rlb.BOOK_ID))
@ -70,27 +104,46 @@ class ReadProgressDtoDao(
.fetch()
.first()
return booksCountToDto(booksCountRecord, indexedReadProgress)
val booksCount = BooksCount(
unreadCount = booksCountRecord.get(BOOKS_UNREAD_COUNT, Int::class.java),
readCount = booksCountRecord.get(BOOKS_READ_COUNT, Int::class.java),
inProgressCount = booksCountRecord.get(BOOKS_IN_PROGRESS_COUNT, Int::class.java),
)
return booksCountToDto(booksCount, indexedReadProgress.lastRead() ?: 0)
}
private fun booksCountToDto(booksCountRecord: Record, indexedReadProgress: List<Record2<Int, Boolean>>): TachiyomiReadProgressDto {
val booksUnreadCount = booksCountRecord.get(BOOKS_UNREAD_COUNT, Int::class.java)
val booksReadCount = booksCountRecord.get(BOOKS_READ_COUNT, Int::class.java)
val booksInProgressCount = booksCountRecord.get(BOOKS_IN_PROGRESS_COUNT, Int::class.java)
val lastReadContinuousIndex = indexedReadProgress
.takeWhile { it.component2() == true }
.lastOrNull()
?.component1() ?: 0
return TachiyomiReadProgressDto(
booksCount = booksUnreadCount + booksReadCount + booksInProgressCount,
booksUnreadCount = booksUnreadCount,
booksInProgressCount = booksInProgressCount,
booksReadCount = booksReadCount,
private fun booksCountToDto(booksCount: BooksCount, lastReadContinuousIndex: Int): TachiyomiReadProgressDto =
TachiyomiReadProgressDto(
booksCount = booksCount.totalCount,
booksUnreadCount = booksCount.unreadCount,
booksInProgressCount = booksCount.inProgressCount,
booksReadCount = booksCount.readCount,
lastReadContinuousIndex = lastReadContinuousIndex,
)
}
private fun booksCountToDtoV2(booksCount: BooksCount, lastReadContinuousNumberSort: Float): TachiyomiReadProgressV2Dto =
TachiyomiReadProgressV2Dto(
booksCount = booksCount.totalCount,
booksUnreadCount = booksCount.unreadCount,
booksInProgressCount = booksCount.inProgressCount,
booksReadCount = booksCount.readCount,
lastReadContinuousNumberSort = lastReadContinuousNumberSort,
)
private fun readProgressCondition(userId: String): Condition = r.USER_ID.eq(userId).or(r.USER_ID.isNull)
private fun <T> List<Record2<T, Boolean>>.lastRead(): T? =
this.takeWhile { it.component2() == true }
.lastOrNull()
?.component1()
private data class BooksCount(
val unreadCount: Int,
val readCount: Int,
val inProgressCount: Int,
) {
val totalCount
get() = unreadCount + readCount + inProgressCount
}
}

View file

@ -18,7 +18,6 @@ import org.gotson.komga.jooq.Tables
import org.gotson.komga.jooq.tables.records.BookMetadataAggregationRecord
import org.gotson.komga.jooq.tables.records.SeriesMetadataRecord
import org.gotson.komga.jooq.tables.records.SeriesRecord
import org.jooq.AggregateFunction
import org.jooq.Condition
import org.jooq.DSLContext
import org.jooq.Record
@ -34,7 +33,6 @@ 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.math.BigDecimal
import java.net.URL
private val logger = KotlinLogging.logger {}
@ -50,22 +48,15 @@ class SeriesDtoDao(
private val luceneHelper: LuceneHelper,
) : SeriesDtoRepository {
companion object {
private val s = Tables.SERIES
private val d = Tables.SERIES_METADATA
private val r = Tables.READ_PROGRESS
private val rs = Tables.READ_PROGRESS_SERIES
private val cs = Tables.COLLECTION_SERIES
private val g = Tables.SERIES_METADATA_GENRE
private val st = Tables.SERIES_METADATA_TAG
private val bma = Tables.BOOK_METADATA_AGGREGATION
private val bmaa = Tables.BOOK_METADATA_AGGREGATION_AUTHOR
private val bmat = Tables.BOOK_METADATA_AGGREGATION_TAG
val countUnread: AggregateFunction<BigDecimal> = DSL.sum(DSL.`when`(r.COMPLETED.isNull, 1).otherwise(0))
val countRead: AggregateFunction<BigDecimal> = DSL.sum(DSL.`when`(r.COMPLETED.isTrue, 1).otherwise(0))
val countInProgress: AggregateFunction<BigDecimal> = DSL.sum(DSL.`when`(r.COMPLETED.isFalse, 1).otherwise(0))
}
private val s = Tables.SERIES
private val d = Tables.SERIES_METADATA
private val rs = Tables.READ_PROGRESS_SERIES
private val cs = Tables.COLLECTION_SERIES
private val g = Tables.SERIES_METADATA_GENRE
private val st = Tables.SERIES_METADATA_TAG
private val bma = Tables.BOOK_METADATA_AGGREGATION
private val bmaa = Tables.BOOK_METADATA_AGGREGATION_AUTHOR
private val bmat = Tables.BOOK_METADATA_AGGREGATION_TAG
private val groupFields = arrayOf(
*s.fields(),

View file

@ -44,6 +44,8 @@ import org.gotson.komga.interfaces.rest.dto.SeriesDto
import org.gotson.komga.interfaces.rest.dto.SeriesMetadataUpdateDto
import org.gotson.komga.interfaces.rest.dto.TachiyomiReadProgressDto
import org.gotson.komga.interfaces.rest.dto.TachiyomiReadProgressUpdateDto
import org.gotson.komga.interfaces.rest.dto.TachiyomiReadProgressUpdateV2Dto
import org.gotson.komga.interfaces.rest.dto.TachiyomiReadProgressV2Dto
import org.gotson.komga.interfaces.rest.dto.restrictUrl
import org.gotson.komga.interfaces.rest.dto.toDto
import org.gotson.komga.interfaces.rest.persistence.BookDtoRepository
@ -81,7 +83,7 @@ import javax.validation.Valid
private val logger = KotlinLogging.logger {}
@RestController
@RequestMapping("api/v1/series", produces = [MediaType.APPLICATION_JSON_VALUE])
@RequestMapping("api", produces = [MediaType.APPLICATION_JSON_VALUE])
class SeriesController(
private val taskReceiver: TaskReceiver,
private val seriesRepository: SeriesRepository,
@ -104,7 +106,7 @@ class SeriesController(
`in` = ParameterIn.QUERY, name = "search_regex", schema = Schema(type = "string")
)
)
@GetMapping
@GetMapping("v1/series")
fun getAllSeries(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "search", required = false) searchTerm: String?,
@ -173,7 +175,7 @@ class SeriesController(
`in` = ParameterIn.QUERY, name = "search_regex", schema = Schema(type = "string")
)
)
@GetMapping("alphabetical-groups")
@GetMapping("v1/series/alphabetical-groups")
fun getAlphabeticalGroups(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "search", required = false) searchTerm: String?,
@ -220,7 +222,7 @@ class SeriesController(
@Operation(description = "Return recently added or updated series.")
@PageableWithoutSortAsQueryParam
@GetMapping("/latest")
@GetMapping("v1/series//latest")
fun getLatestSeries(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "library_id", required = false) libraryIds: List<String>?,
@ -250,7 +252,7 @@ class SeriesController(
@Operation(description = "Return newly added series.")
@PageableWithoutSortAsQueryParam
@GetMapping("/new")
@GetMapping("v1/series/new")
fun getNewSeries(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "library_id", required = false) libraryIds: List<String>?,
@ -280,7 +282,7 @@ class SeriesController(
@Operation(description = "Return recently updated series, but not newly added ones.")
@PageableWithoutSortAsQueryParam
@GetMapping("/updated")
@GetMapping("v1/series/updated")
fun getUpdatedSeries(
@AuthenticationPrincipal principal: KomgaPrincipal,
@RequestParam(name = "library_id", required = false) libraryIds: List<String>?,
@ -308,7 +310,7 @@ class SeriesController(
).map { it.restrictUrl(!principal.user.roleAdmin) }
}
@GetMapping("{seriesId}")
@GetMapping("v1/series/{seriesId}")
fun getOneSeries(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "seriesId") id: String
@ -319,7 +321,7 @@ class SeriesController(
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@ApiResponse(content = [Content(schema = Schema(type = "string", format = "binary"))])
@GetMapping(value = ["{seriesId}/thumbnail"], produces = [MediaType.IMAGE_JPEG_VALUE])
@GetMapping(value = ["v1/series/{seriesId}/thumbnail"], produces = [MediaType.IMAGE_JPEG_VALUE])
fun getSeriesThumbnail(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "seriesId") seriesId: String
@ -334,7 +336,7 @@ class SeriesController(
@PageableAsQueryParam
@AuthorsAsQueryParam
@GetMapping("{seriesId}/books")
@GetMapping("v1/series/{seriesId}/books")
fun getAllBooksBySeries(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "seriesId") seriesId: String,
@ -375,7 +377,7 @@ class SeriesController(
).map { it.restrictUrl(!principal.user.roleAdmin) }
}
@GetMapping("{seriesId}/collections")
@GetMapping("v1/series/{seriesId}/collections")
fun getAllCollectionsBySeries(
@AuthenticationPrincipal principal: KomgaPrincipal,
@PathVariable(name = "seriesId") seriesId: String
@ -388,7 +390,7 @@ class SeriesController(
.map { it.toDto() }
}
@PostMapping("{seriesId}/analyze")
@PostMapping("v1/series/{seriesId}/analyze")
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@ResponseStatus(HttpStatus.ACCEPTED)
fun analyze(@PathVariable seriesId: String) {
@ -397,7 +399,7 @@ class SeriesController(
}
}
@PostMapping("{seriesId}/metadata/refresh")
@PostMapping("v1/series/{seriesId}/metadata/refresh")
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@ResponseStatus(HttpStatus.ACCEPTED)
fun refreshMetadata(@PathVariable seriesId: String) {
@ -408,7 +410,7 @@ class SeriesController(
taskReceiver.refreshSeriesLocalArtwork(seriesId, priority = HIGH_PRIORITY)
}
@PatchMapping("{seriesId}/metadata")
@PatchMapping("v1/series/{seriesId}/metadata")
@PreAuthorize("hasRole('$ROLE_ADMIN')")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun updateMetadata(
@ -454,7 +456,7 @@ class SeriesController(
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@Operation(description = "Mark all book for series as read")
@PostMapping("{seriesId}/read-progress")
@PostMapping("v1/series/{seriesId}/read-progress")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun markAsRead(
@PathVariable seriesId: String,
@ -468,7 +470,7 @@ class SeriesController(
}
@Operation(description = "Mark all book for series as unread")
@DeleteMapping("{seriesId}/read-progress")
@DeleteMapping("v1/series/{seriesId}/read-progress")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun markAsUnread(
@PathVariable seriesId: String,
@ -481,7 +483,8 @@ class SeriesController(
seriesLifecycle.deleteReadProgress(seriesId, principal.user)
}
@GetMapping("{seriesId}/read-progress/tachiyomi")
@Deprecated("Use v2 for proper handling of chapter number with numberSort")
@GetMapping("v1/series/{seriesId}/read-progress/tachiyomi")
fun getReadProgressTachiyomi(
@PathVariable seriesId: String,
@AuthenticationPrincipal principal: KomgaPrincipal,
@ -491,7 +494,18 @@ class SeriesController(
return readProgressDtoRepository.findProgressBySeries(seriesId, principal.user.id)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@PutMapping("{seriesId}/read-progress/tachiyomi")
@GetMapping("v2/series/{seriesId}/read-progress/tachiyomi")
fun getReadProgressTachiyomiV2(
@PathVariable seriesId: String,
@AuthenticationPrincipal principal: KomgaPrincipal,
): TachiyomiReadProgressV2Dto =
seriesRepository.getLibraryId(seriesId)?.let {
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
return readProgressDtoRepository.findProgressV2BySeries(seriesId, principal.user.id)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
@Deprecated("Use v2 for proper handling of chapter number with numberSort")
@PutMapping("v1/series/{seriesId}/read-progress/tachiyomi")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun markReadProgressTachiyomi(
@PathVariable seriesId: String,
@ -513,7 +527,29 @@ class SeriesController(
}
}
@GetMapping("{seriesId}/file", produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE])
@PutMapping("v2/series/{seriesId}/read-progress/tachiyomi")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun markReadProgressTachiyomiV2(
@PathVariable seriesId: String,
@RequestBody readProgress: TachiyomiReadProgressUpdateV2Dto,
@AuthenticationPrincipal principal: KomgaPrincipal,
) {
seriesRepository.getLibraryId(seriesId)?.let {
if (!principal.user.canAccessLibrary(it)) throw ResponseStatusException(HttpStatus.FORBIDDEN)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
bookDtoRepository.findAll(
BookSearchWithReadProgress(seriesIds = listOf(seriesId)),
principal.user.id,
UnpagedSorted(Sort.by(Sort.Order.asc("metadata.numberSort"))),
).toList().filter { book -> book.metadata.numberSort <= readProgress.lastBookNumberSortRead }
.forEach { book ->
if (book.readProgress?.completed != true)
bookLifecycle.markReadProgressCompleted(book.id, principal.user)
}
}
@GetMapping("v1/series/{seriesId}/file", produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE])
@PreAuthorize("hasRole('$ROLE_FILE_DOWNLOAD')")
fun getSeriesFile(
@AuthenticationPrincipal principal: KomgaPrincipal,

View file

@ -3,7 +3,6 @@ package org.gotson.komga.interfaces.rest.dto
import com.fasterxml.jackson.annotation.JsonFormat
import java.time.LocalDate
import java.time.LocalDateTime
import javax.validation.constraints.PositiveOrZero
data class SeriesDto(
val id: String,
@ -71,7 +70,3 @@ data class BookMetadataAggregationDto(
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val lastModified: LocalDateTime
)
data class TachiyomiReadProgressUpdateDto(
@get:PositiveOrZero val lastBookRead: Int,
)

View file

@ -0,0 +1,11 @@
package org.gotson.komga.interfaces.rest.dto
import javax.validation.constraints.PositiveOrZero
data class TachiyomiReadProgressUpdateDto(
@get:PositiveOrZero val lastBookRead: Int,
)
data class TachiyomiReadProgressUpdateV2Dto(
val lastBookNumberSortRead: Float,
)

View file

@ -0,0 +1,9 @@
package org.gotson.komga.interfaces.rest.dto
data class TachiyomiReadProgressV2Dto(
val booksCount: Int,
val booksReadCount: Int,
val booksUnreadCount: Int,
val booksInProgressCount: Int,
val lastReadContinuousNumberSort: Float,
)

View file

@ -1,8 +1,10 @@
package org.gotson.komga.interfaces.rest.persistence
import org.gotson.komga.interfaces.rest.dto.TachiyomiReadProgressDto
import org.gotson.komga.interfaces.rest.dto.TachiyomiReadProgressV2Dto
interface ReadProgressDtoRepository {
fun findProgressBySeries(seriesId: String, userId: String,): TachiyomiReadProgressDto
fun findProgressV2BySeries(seriesId: String, userId: String,): TachiyomiReadProgressV2Dto
fun findProgressByReadList(readListId: String, userId: String,): TachiyomiReadProgressDto
}