diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt index d2c5996b7..38081e600 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookSearch.kt @@ -4,7 +4,8 @@ open class BookSearch( val libraryIds: Collection? = null, val seriesIds: Collection? = null, val searchTerm: String? = null, - val mediaStatus: Collection? = null + val mediaStatus: Collection? = null, + val deleted: Boolean? = null, ) class BookSearchWithReadProgress( diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt index 15dad4468..8032a06e4 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesSearch.kt @@ -5,7 +5,8 @@ open class SeriesSearch( val collectionIds: Collection? = null, val searchTerm: String? = null, val metadataStatus: Collection? = null, - val publishers: Collection? = null + val publishers: Collection? = null, + val deleted: Boolean? = null, ) class SeriesSearchWithReadProgress( diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDao.kt index be02af202..1c0db8ee1 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/BookDao.kt @@ -271,6 +271,8 @@ class BookDao( if (!seriesIds.isNullOrEmpty()) c = c.and(b.SERIES_ID.`in`(seriesIds)) searchTerm?.let { c = c.and(d.TITLE.containsIgnoreCase(it)) } if (!mediaStatus.isNullOrEmpty()) c = c.and(m.STATUS.`in`(mediaStatus)) + if (deleted == true) c = c.and(b.DELETED_DATE.isNotNull) + if (deleted == false) c = c.and(b.DELETED_DATE.isNull) return c } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDao.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDao.kt index 12fee17f0..3b4e04482 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDao.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/jooq/SeriesDao.kt @@ -129,6 +129,8 @@ class SeriesDao( searchTerm?.let { c = c.and(d.TITLE.containsIgnoreCase(it)) } if (!metadataStatus.isNullOrEmpty()) c = c.and(d.STATUS.`in`(metadataStatus)) if (!publishers.isNullOrEmpty()) c = c.and(DSL.lower(d.PUBLISHER).`in`(publishers.map { it.lowercase() })) + if (deleted == true) c = c.and(s.DELETED_DATE.isNotNull) + if (deleted == false) c = c.and(s.DELETED_DATE.isNull) return c } diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/opds/OpdsController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/opds/OpdsController.kt index 5a486308f..66d687d77 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/opds/OpdsController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/opds/OpdsController.kt @@ -189,7 +189,8 @@ class OpdsController( val seriesSearch = SeriesSearch( libraryIds = principal.user.getAuthorizedLibraryIds(null), searchTerm = searchTerm, - publishers = publishers + publishers = publishers, + deleted = false, ) val entries = seriesRepository.findAll(seriesSearch) @@ -215,7 +216,8 @@ class OpdsController( @AuthenticationPrincipal principal: KomgaPrincipal ): OpdsFeed { val seriesSearch = SeriesSearch( - libraryIds = principal.user.getAuthorizedLibraryIds(null) + libraryIds = principal.user.getAuthorizedLibraryIds(null), + deleted = false, ) val entries = seriesRepository.findAll(seriesSearch) @@ -243,7 +245,8 @@ class OpdsController( ): OpdsFeed { val bookSearch = BookSearch( libraryIds = principal.user.getAuthorizedLibraryIds(null), - mediaStatus = setOf(Media.Status.READY) + mediaStatus = setOf(Media.Status.READY), + deleted = false, ) val pageRequest = PageRequest.of(0, 50, Sort.by(Sort.Order.desc("createdDate"))) @@ -376,7 +379,8 @@ class OpdsController( val books = bookRepository.findAll( BookSearch( seriesIds = listOf(id), - mediaStatus = setOf(Media.Status.READY) + mediaStatus = setOf(Media.Status.READY), + deleted = false, ) ) val metadata = seriesMetadataRepository.findById(series.id) @@ -407,7 +411,10 @@ class OpdsController( libraryRepository.findByIdOrNull(id)?.let { library -> if (!principal.user.canAccessLibrary(library)) throw ResponseStatusException(HttpStatus.FORBIDDEN) - val seriesSearch = SeriesSearch(libraryIds = setOf(library.id)) + val seriesSearch = SeriesSearch( + libraryIds = setOf(library.id), + deleted = false, + ) val entries = seriesRepository.findAll(seriesSearch) .map { SeriesWithInfo(it, seriesMetadataRepository.findById(it.id)) } @@ -434,6 +441,7 @@ class OpdsController( ): OpdsFeed { return collectionRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { collection -> val series = collection.seriesIds.mapNotNull { seriesRepository.findByIdOrNull(it) } + .filterNot { it.deletedDate != null } .map { SeriesWithInfo(it, seriesMetadataRepository.findById(it.id)) } val sorted = @@ -465,6 +473,7 @@ class OpdsController( ): OpdsFeed { return readListRepository.findByIdOrNull(id, principal.user.getAuthorizedLibraryIds(null))?.let { readList -> val books = readList.bookIds.values.mapNotNull { bookRepository.findByIdOrNull(it) } + .filterNot { it.deletedDate != null } .map { BookWithInfo(it, mediaRepository.findById(it.id), bookMetadataRepository.findById(it.id)) } val entries = books.mapIndexed { index, it -> diff --git a/komga/src/test/kotlin/org/gotson/komga/interfaces/opds/OpdsControllerTest.kt b/komga/src/test/kotlin/org/gotson/komga/interfaces/opds/OpdsControllerTest.kt new file mode 100644 index 000000000..67fa65fe6 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/interfaces/opds/OpdsControllerTest.kt @@ -0,0 +1,260 @@ +package org.gotson.komga.interfaces.opds + +import org.gotson.komga.domain.model.BookPage +import org.gotson.komga.domain.model.KomgaUser +import org.gotson.komga.domain.model.Media +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.MediaRepository +import org.gotson.komga.domain.persistence.SeriesMetadataRepository +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.SeriesLifecycle +import org.gotson.komga.interfaces.rest.WithMockCustomUser +import org.hamcrest.Matchers +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.test.context.junit.jupiter.SpringExtension +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import java.time.LocalDateTime + +@ExtendWith(SpringExtension::class) +@SpringBootTest +@AutoConfigureMockMvc(printOnlyOnFailure = false) +class OpdsControllerTest( + @Autowired private val seriesRepository: SeriesRepository, + @Autowired private val seriesLifecycle: SeriesLifecycle, + @Autowired private val seriesMetadataRepository: SeriesMetadataRepository, + @Autowired private val libraryRepository: LibraryRepository, + @Autowired private val libraryLifecycle: LibraryLifecycle, + @Autowired private val bookRepository: BookRepository, + @Autowired private val mediaRepository: MediaRepository, + @Autowired private val userRepository: KomgaUserRepository, + @Autowired private val userLifecycle: KomgaUserLifecycle, + @Autowired private val mockMvc: MockMvc +) { + + private val library = makeLibrary(id = "1") + private val user = KomgaUser("user@example.org", "", false, id = "1") + private val user2 = KomgaUser("user2@example.org", "", false, id = "2") + + @BeforeAll + fun `setup library`() { + libraryRepository.insert(library) + userRepository.insert(user) + userRepository.insert(user2) + } + + @AfterAll + fun teardown() { + userRepository.findAll().forEach { + userLifecycle.deleteUser(it) + } + libraryRepository.findAll().forEach { + libraryLifecycle.deleteLibrary(it) + } + } + + @AfterEach + fun `clear repository`() { + seriesLifecycle.deleteMany(seriesRepository.findAll()) + } + + @Nested + inner class LimitedUser { + @Test + @WithMockCustomUser(sharedAllLibraries = false, sharedLibraries = ["1"]) + fun `given user with access to a single library when getting series then only gets series from this library`() { + val createdSeries = makeSeries(name = "series", libraryId = library.id).also { series -> + seriesLifecycle.createSeries(series).let { created -> + val books = listOf(makeBook("1", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val otherLibrary = makeLibrary("other") + libraryRepository.insert(otherLibrary) + makeSeries(name = "otherSeries", libraryId = otherLibrary.id).let { series -> + seriesLifecycle.createSeries(series).let { created -> + val otherBooks = listOf(makeBook("2", libraryId = otherLibrary.id)) + seriesLifecycle.addBooks(created, otherBooks) + } + } + + mockMvc.get("/opds/v1.2/series") + .andExpect { + status { isOk() } + xpath("/feed/entry/id") { + nodeCount(1) + string(createdSeries.id) + } + } + } + } + + @Nested + inner class SeriesSort { + @Test + @WithMockCustomUser + fun `given series with titleSort when requesting via opds then series are sorted by titleSort`() { + val alphaC = seriesLifecycle.createSeries(makeSeries("TheAlpha", libraryId = library.id)) + seriesMetadataRepository.findById(alphaC.id).let { + seriesMetadataRepository.update(it.copy(titleSort = "Alpha, The")) + } + seriesLifecycle.createSeries(makeSeries("Beta", libraryId = library.id)) + + mockMvc.get("/opds/v1.2/series") + .andExpect { + status { isOk() } + xpath("/feed/entry[1]/title") { string("TheAlpha") } + xpath("/feed/entry[2]/title") { string("Beta") } + } + } + + @Test + @WithMockCustomUser + fun `given series when requesting via opds then series are sorted insensitive of case`() { + listOf("a", "b", "B", "C") + .map { name -> makeSeries(name, libraryId = library.id) } + .forEach { + seriesLifecycle.createSeries(it) + } + + mockMvc.get("/opds/v1.2/series") + .andExpect { + status { isOk() } + xpath("/feed/entry[1]/title") { string("a") } + xpath("/feed/entry[2]/title") { string(Matchers.equalToIgnoringCase("b")) } + xpath("/feed/entry[3]/title") { string(Matchers.equalToIgnoringCase("b")) } + xpath("/feed/entry[4]/title") { string("C") } + } + } + } + + @Nested + inner class SeriesStatus { + @Test + @WithMockCustomUser + fun `given series when requesting via opds then deleted series are not returned`() { + seriesLifecycle.createSeries(makeSeries("Alpha", libraryId = library.id)).also { + seriesRepository.update(it.copy(deletedDate = LocalDateTime.now())) + } + seriesLifecycle.createSeries(makeSeries("Beta", libraryId = library.id)) + + mockMvc.get("/opds/v1.2/series") + .andExpect { + status { isOk() } + xpath("/feed/entry") { nodeCount(1) } + xpath("/feed/entry[1]/title") { string("Beta") } + } + } + } + + @Nested + inner class BookOrdering { + @Test + @WithMockCustomUser + fun `given books with unordered index when requesting via opds then books are ordered`() { + val createdSeries = makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id), makeBook("3", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val addedBook = makeBook("2", libraryId = library.id) + seriesLifecycle.addBooks(createdSeries, listOf(addedBook)) + seriesLifecycle.sortBooks(createdSeries) + + bookRepository.findAll().forEach { + mediaRepository.findById(it.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, pages = listOf(BookPage("1.jpg", "image/jpeg")))) + } + } + + mockMvc.get("/opds/v1.2/series/${createdSeries.id}") + .andExpect { + status { isOk() } + xpath("/feed/entry[1]/title") { string("1") } + xpath("/feed/entry[2]/title") { string("2") } + xpath("/feed/entry[3]/title") { string("3") } + } + } + } + + @Nested + inner class BookStatus { + @Test + @WithMockCustomUser + fun `given books not ready when requesting via opds then no books are returned`() { + val createdSeries = makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id), makeBook("3", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + bookRepository.findAll().forEach { + mediaRepository.findById(it.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, pages = listOf(BookPage("1.jpg", "image/jpeg")))) + } + } + + val addedBook = makeBook("2", libraryId = library.id) + seriesLifecycle.addBooks(createdSeries, listOf(addedBook)) + seriesLifecycle.sortBooks(createdSeries) + + mockMvc.get("/opds/v1.2/series/${createdSeries.id}") + .andExpect { + status { isOk() } + xpath("/feed/entry") { nodeCount(2) } + xpath("/feed/entry[1]/title") { string("1") } + xpath("/feed/entry[2]/title") { string("3") } + } + } + + @Test + @WithMockCustomUser + fun `given deleted ready books when requesting via opds then no books are returned`() { + val createdSeries = makeSeries(name = "series", libraryId = library.id).let { series -> + seriesLifecycle.createSeries(series).also { created -> + val books = listOf(makeBook("1", libraryId = library.id), makeBook("3", libraryId = library.id)) + seriesLifecycle.addBooks(created, books) + } + } + + val addedBook = makeBook("2", libraryId = library.id) + seriesLifecycle.addBooks(createdSeries, listOf(addedBook)) + seriesLifecycle.sortBooks(createdSeries) + + bookRepository.findAll().forEach { + mediaRepository.findById(it.id).let { media -> + mediaRepository.update(media.copy(status = Media.Status.READY, pages = listOf(BookPage("1.jpg", "image/jpeg")))) + } + if (it.id == addedBook.id) + bookRepository.update(it.copy(deletedDate = LocalDateTime.now())) + } + + mockMvc.get("/opds/v1.2/series/${createdSeries.id}") + .andExpect { + status { isOk() } + xpath("/feed/entry") { nodeCount(2) } + xpath("/feed/entry[1]/title") { string("1") } + xpath("/feed/entry[2]/title") { string("3") } + } + } + } +}