diff --git a/komga/src/main/kotlin/db/migration/V20200121154334__create_series_metadata_from_series.kt b/komga/src/main/kotlin/db/migration/V20200121154334__create_series_metadata_from_series.kt new file mode 100644 index 000000000..f34f1765a --- /dev/null +++ b/komga/src/main/kotlin/db/migration/V20200121154334__create_series_metadata_from_series.kt @@ -0,0 +1,23 @@ +package db.migration + +import org.flywaydb.core.api.migration.BaseJavaMigration +import org.flywaydb.core.api.migration.Context +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.jdbc.datasource.SingleConnectionDataSource +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +class V20200121154334__create_series_metadata_from_series : BaseJavaMigration() { + override fun migrate(context: Context) { + val jdbcTemplate = JdbcTemplate(SingleConnectionDataSource(context.connection, true)) + + val seriesIds = jdbcTemplate.queryForList("SELECT id FROM series", Long::class.java) + + val now = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss")) + seriesIds.forEach { seriesId -> + val metadataId = jdbcTemplate.queryForObject("SELECT NEXTVAL('hibernate_sequence')", Int::class.java) + jdbcTemplate.execute("INSERT INTO series_metadata (ID, CREATED_DATE, LAST_MODIFIED_DATE, STATUS) VALUES ($metadataId, '$now', '$now', 'ONGOING')") + jdbcTemplate.execute("UPDATE series SET metadata_id = $metadataId WHERE id = $seriesId") + } + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Series.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Series.kt index 1cd8aa606..c88de22e8 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/Series.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Series.kt @@ -16,6 +16,7 @@ import javax.persistence.Id import javax.persistence.JoinColumn import javax.persistence.ManyToOne import javax.persistence.OneToMany +import javax.persistence.OneToOne import javax.persistence.Table import javax.validation.constraints.NotBlank import javax.validation.constraints.NotNull @@ -63,6 +64,10 @@ class Series( _books.forEachIndexed { index, book -> book.number = index + 1F } } + @OneToOne(optional = false, orphanRemoval = true, cascade = [CascadeType.ALL], fetch = FetchType.LAZY) + @JoinColumn(name = "metadata_id", nullable = false) + var metadata: SeriesMetadata = SeriesMetadata() + init { this.books = books.toList() } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadata.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadata.kt new file mode 100644 index 000000000..a592744d5 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadata.kt @@ -0,0 +1,32 @@ +package org.gotson.komga.domain.model + +import org.hibernate.annotations.Cache +import org.hibernate.annotations.CacheConcurrencyStrategy +import javax.persistence.Cacheable +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.EnumType +import javax.persistence.Enumerated +import javax.persistence.GeneratedValue +import javax.persistence.Id +import javax.persistence.Table + +@Entity +@Table(name = "series_metadata") +@Cacheable +@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.series_metadata") +class SeriesMetadata( + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: Status = Status.ONGOING + +) : AuditableEntity() { + @Id + @GeneratedValue + @Column(name = "id", nullable = false, unique = true) + val id: Long = 0 + + enum class Status { + ENDED, ONGOING, ABANDONED, HIATUS + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt index c4541e1aa..c02d4cafc 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/SeriesController.kt @@ -13,6 +13,7 @@ import org.gotson.komga.domain.persistence.SeriesRepository import org.gotson.komga.infrastructure.security.KomgaPrincipal import org.gotson.komga.interfaces.rest.dto.BookDto import org.gotson.komga.interfaces.rest.dto.SeriesDto +import org.gotson.komga.interfaces.rest.dto.SeriesMetadataUpdateDto import org.gotson.komga.interfaces.rest.dto.toDto import org.springframework.data.domain.Page import org.springframework.data.domain.PageRequest @@ -26,8 +27,10 @@ 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.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 @@ -49,16 +52,16 @@ class SeriesController( @GetMapping fun getAllSeries( - @AuthenticationPrincipal principal: KomgaPrincipal, - @RequestParam(name = "search", required = false) searchTerm: String?, - @RequestParam(name = "library_id", required = false) libraryIds: List?, - page: Pageable + @AuthenticationPrincipal principal: KomgaPrincipal, + @RequestParam(name = "search", required = false) searchTerm: String?, + @RequestParam(name = "library_id", required = false) libraryIds: List?, + page: Pageable ): Page { val pageRequest = PageRequest.of( - page.pageNumber, - page.pageSize, - if (page.sort.isSorted) page.sort - else Sort.by(Sort.Order.asc("name").ignoreCase()) + page.pageNumber, + page.pageSize, + if (page.sort.isSorted) page.sort + else Sort.by(Sort.Order.asc("name").ignoreCase()) ) return mutableListOf>().let { specs -> @@ -94,13 +97,13 @@ class SeriesController( // all updated series, whether newly added or updated @GetMapping("/latest") fun getLatestSeries( - @AuthenticationPrincipal principal: KomgaPrincipal, - page: Pageable + @AuthenticationPrincipal principal: KomgaPrincipal, + page: Pageable ): Page { val pageRequest = PageRequest.of( - page.pageNumber, - page.pageSize, - Sort.by(Sort.Direction.DESC, "lastModifiedDate") + page.pageNumber, + page.pageSize, + Sort.by(Sort.Direction.DESC, "lastModifiedDate") ) return if (principal.user.sharedAllLibraries) { @@ -113,13 +116,13 @@ class SeriesController( // new series only, doesn't contain existing updated series @GetMapping("/new") fun getNewSeries( - @AuthenticationPrincipal principal: KomgaPrincipal, - page: Pageable + @AuthenticationPrincipal principal: KomgaPrincipal, + page: Pageable ): Page { val pageRequest = PageRequest.of( - page.pageNumber, - page.pageSize, - Sort.by(Sort.Direction.DESC, "createdDate") + page.pageNumber, + page.pageSize, + Sort.by(Sort.Direction.DESC, "createdDate") ) return if (principal.user.sharedAllLibraries) { @@ -132,13 +135,13 @@ class SeriesController( // updated series only, doesn't contain new series @GetMapping("/updated") fun getUpdatedSeries( - @AuthenticationPrincipal principal: KomgaPrincipal, - page: Pageable + @AuthenticationPrincipal principal: KomgaPrincipal, + page: Pageable ): Page { val pageRequest = PageRequest.of( - page.pageNumber, - page.pageSize, - Sort.by(Sort.Direction.DESC, "lastModifiedDate") + page.pageNumber, + page.pageSize, + Sort.by(Sort.Direction.DESC, "lastModifiedDate") ) return if (principal.user.sharedAllLibraries) { @@ -150,41 +153,41 @@ class SeriesController( @GetMapping("{seriesId}") fun getOneSeries( - @AuthenticationPrincipal principal: KomgaPrincipal, - @PathVariable(name = "seriesId") id: Long + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "seriesId") id: Long ): SeriesDto = - seriesRepository.findByIdOrNull(id)?.let { - if (!principal.user.canAccessSeries(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) - it.toDto(includeUrl = principal.user.isAdmin()) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + seriesRepository.findByIdOrNull(id)?.let { + if (!principal.user.canAccessSeries(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) + it.toDto(includeUrl = principal.user.isAdmin()) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @GetMapping(value = ["{seriesId}/thumbnail"], produces = [MediaType.IMAGE_JPEG_VALUE]) fun getSeriesThumbnail( - @AuthenticationPrincipal principal: KomgaPrincipal, - request: WebRequest, - @PathVariable(name = "seriesId") id: Long + @AuthenticationPrincipal principal: KomgaPrincipal, + request: WebRequest, + @PathVariable(name = "seriesId") id: Long ): ResponseEntity = - seriesRepository.findByIdOrNull(id)?.let { series -> - if (!principal.user.canAccessSeries(series)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) + seriesRepository.findByIdOrNull(id)?.let { series -> + if (!principal.user.canAccessSeries(series)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) - series.books.minBy { it.number }?.let { firstBook -> - bookController.getBookThumbnail(principal, request, firstBook.id) - } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + series.books.minBy { it.number }?.let { firstBook -> + bookController.getBookThumbnail(principal, request, firstBook.id) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) @GetMapping("{seriesId}/books") fun getAllBooksBySeries( - @AuthenticationPrincipal principal: KomgaPrincipal, - @PathVariable(name = "seriesId") id: Long, - @RequestParam(name = "media_status", required = false) mediaStatus: List?, - page: Pageable + @AuthenticationPrincipal principal: KomgaPrincipal, + @PathVariable(name = "seriesId") id: Long, + @RequestParam(name = "media_status", required = false) mediaStatus: List?, + page: Pageable ): Page { seriesRepository.findByIdOrNull(id)?.let { if (!principal.user.canAccessSeries(it)) throw ResponseStatusException(HttpStatus.UNAUTHORIZED) } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) val pageRequest = PageRequest.of( - page.pageNumber, + page.pageNumber, page.pageSize, if (page.sort.isSorted) page.sort else Sort.by(Sort.Order.asc("number")) @@ -208,4 +211,16 @@ class SeriesController( } } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } + + @PatchMapping("{seriesId}/metadata") + @PreAuthorize("hasRole('ROLE_ADMIN')") + fun updateMetadata( + @PathVariable seriesId: Long, + @RequestBody newMetadata: SeriesMetadataUpdateDto + ): SeriesDto = + seriesRepository.findByIdOrNull(seriesId)?.let { series -> + newMetadata.status?.let { series.metadata.status = newMetadata.status } + seriesRepository.save(series).toDto(includeUrl = true) + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/SeriesDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/SeriesDto.kt index b9cae2147..1bd36054a 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/SeriesDto.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/SeriesDto.kt @@ -15,7 +15,16 @@ data class SeriesDto( val lastModified: LocalDateTime?, @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") val fileLastModified: LocalDateTime, - val booksCount: Int + val booksCount: Int, + val metadata: SeriesMetadataDto +) + +data class SeriesMetadataDto( + val status: String, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + val created: LocalDateTime?, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + val lastModified: LocalDateTime? ) fun Series.toDto(includeUrl: Boolean) = SeriesDto( @@ -26,5 +35,10 @@ fun Series.toDto(includeUrl: Boolean) = SeriesDto( created = createdDate?.toUTC(), lastModified = lastModifiedDate?.toUTC(), fileLastModified = fileLastModified.toUTC(), - booksCount = books.size + booksCount = books.size, + metadata = SeriesMetadataDto( + status = metadata.status.name, + created = metadata.createdDate?.toUTC(), + lastModified = metadata.lastModifiedDate?.toUTC() + ) ) diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/SeriesMetadataUpdateDto.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/SeriesMetadataUpdateDto.kt new file mode 100644 index 000000000..bda8950bb --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/dto/SeriesMetadataUpdateDto.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.interfaces.rest.dto + +import org.gotson.komga.domain.model.SeriesMetadata + +data class SeriesMetadataUpdateDto( + val status: SeriesMetadata.Status? +) diff --git a/komga/src/main/resources/application.conf b/komga/src/main/resources/application.conf index 2bef4ce27..dbd3ca7dd 100644 --- a/komga/src/main/resources/application.conf +++ b/komga/src/main/resources/application.conf @@ -65,6 +65,17 @@ caffeine.jcache { } } + cache.series_metadata { + monitoring { + statistics = true + } + policy { + maximum { + size = 500 + } + } + } + default-update-timestamps-region { monitoring { statistics = true diff --git a/komga/src/main/resources/db/migration/V20200121153351__series_metadata.sql b/komga/src/main/resources/db/migration/V20200121153351__series_metadata.sql new file mode 100644 index 000000000..68bb8a3c3 --- /dev/null +++ b/komga/src/main/resources/db/migration/V20200121153351__series_metadata.sql @@ -0,0 +1,15 @@ +create table series_metadata +( + id bigint not null, + created_date timestamp not null, + last_modified_date timestamp not null, + status varchar not null, + primary key (id) +); + +alter table series + add (metadata_id bigint); + +alter table series + add constraint fk_series_series_metadata_metadata_id foreign key (metadata_id) references series_metadata (id); + diff --git a/komga/src/main/resources/db/migration/V20200121154845__series_metadata_id_not_null.sql b/komga/src/main/resources/db/migration/V20200121154845__series_metadata_id_not_null.sql new file mode 100644 index 000000000..bba906267 --- /dev/null +++ b/komga/src/main/resources/db/migration/V20200121154845__series_metadata_id_not_null.sql @@ -0,0 +1,2 @@ +alter table series + alter column metadata_id set not null;