feat: hash pages to detect duplicates

This commit is contained in:
Gauthier Roebroeck 2022-01-05 11:28:40 +08:00
parent f23d16d387
commit 195ec29d6d
32 changed files with 338 additions and 33 deletions

View file

@ -0,0 +1,2 @@
ALTER TABLE MEDIA_PAGE
ADD COLUMN FILE_HASH varchar NOT NULL DEFAULT '';

View file

@ -44,6 +44,11 @@ sealed class Task(priority: Int = DEFAULT_PRIORITY) : Serializable {
override fun toString(): String = "HashBook(bookId='$bookId', priority='$priority')"
}
class HashBookPages(val bookId: String, priority: Int = DEFAULT_PRIORITY) : Task(priority) {
override fun uniqueId() = "HASH_BOOK_PAGES_$bookId"
override fun toString(): String = "HashBookPages(bookId='$bookId', priority='$priority')"
}
class RefreshSeriesMetadata(val seriesId: String, priority: Int = DEFAULT_PRIORITY) : Task(priority) {
override fun uniqueId() = "REFRESH_SERIES_METADATA_$seriesId"
override fun toString(): String = "RefreshSeriesMetadata(seriesId='$seriesId', priority='$priority')"

View file

@ -52,6 +52,7 @@ class TaskHandler(
libraryContentLifecycle.scanRootFolder(library)
taskReceiver.analyzeUnknownAndOutdatedBooks(library)
taskReceiver.hashBooksWithoutHash(library)
taskReceiver.hashBookPagesWithMissingHash(library)
if (library.repairExtensions) taskReceiver.repairExtensions(library, LOWEST_PRIORITY)
if (library.convertToCbz) taskReceiver.convertBooksToCbz(library, LOWEST_PRIORITY)
} ?: logger.warn { "Cannot execute task $task: Library does not exist" }
@ -122,6 +123,11 @@ class TaskHandler(
bookLifecycle.hashAndPersist(book)
} ?: logger.warn { "Cannot execute task $task: Book does not exist" }
is Task.HashBookPages ->
bookRepository.findByIdOrNull(task.bookId)?.let { book ->
bookLifecycle.hashPagesAndPersist(book)
} ?: logger.warn { "Cannot execute task $task: Book does not exist" }
is Task.RebuildIndex -> searchIndexLifecycle.rebuildIndex(task.entities)
is Task.DeleteBook -> {

View file

@ -8,6 +8,7 @@ import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Media
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.service.BookConverter
import org.gotson.komga.infrastructure.configuration.KomgaProperties
import org.gotson.komga.infrastructure.jms.QUEUE_SUB_TYPE
@ -28,6 +29,7 @@ class TaskReceiver(
connectionFactory: ConnectionFactory,
private val libraryRepository: LibraryRepository,
private val bookRepository: BookRepository,
private val mediaRepository: MediaRepository,
private val bookConverter: BookConverter,
private val komgaProperties: KomgaProperties,
) {
@ -70,6 +72,12 @@ class TaskReceiver(
}
}
fun hashBookPagesWithMissingHash(library: Library) {
mediaRepository.findAllBookIdsByLibraryIdAndWithMissingPageHash(library.id, komgaProperties.pageHashing).forEach {
submitTask(Task.HashBookPages(it, LOWEST_PRIORITY))
}
}
fun convertBooksToCbz(library: Library, priority: Int = DEFAULT_PRIORITY) {
bookConverter.getConvertibleBookIds(library).forEach {
submitTask(Task.ConvertBook(it, priority))

View file

@ -4,4 +4,5 @@ data class BookPage(
val fileName: String,
val mediaType: String,
val dimension: Dimension? = null,
val fileHash: String = "",
)

View file

@ -5,6 +5,8 @@ import org.gotson.komga.domain.model.Media
interface MediaRepository {
fun findById(bookId: String): Media
fun findAllBookIdsByLibraryIdAndWithMissingPageHash(libraryId: String, pageHashing: Int): Collection<String>
fun getPagesSize(bookId: String): Int
fun getPagesSizes(bookIds: Collection<String>): Collection<Pair<String, Int>>

View file

@ -8,12 +8,17 @@ import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.MediaNotReadyException
import org.gotson.komga.domain.model.MediaUnsupportedException
import org.gotson.komga.domain.model.ThumbnailBook
import org.gotson.komga.infrastructure.hash.Hasher
import org.gotson.komga.infrastructure.image.ImageConverter
import org.gotson.komga.infrastructure.image.ImageType
import org.gotson.komga.infrastructure.mediacontainer.ContentDetector
import org.gotson.komga.infrastructure.mediacontainer.MediaContainerExtractor
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.io.ByteArrayOutputStream
import java.nio.file.AccessDeniedException
import java.nio.file.NoSuchFileException
import javax.imageio.ImageIO
private val logger = KotlinLogging.logger {}
@ -22,6 +27,8 @@ class BookAnalyzer(
private val contentDetector: ContentDetector,
extractors: List<MediaContainerExtractor>,
private val imageConverter: ImageConverter,
private val hasher: Hasher,
@Value("#{@komgaProperties.pageHashing}") private val pageHashing: Int,
) {
val supportedMediaTypes = extractors
@ -115,7 +122,7 @@ class BookAnalyzer(
IndexOutOfBoundsException::class,
)
fun getPageContent(book: BookWithMedia, number: Int): ByteArray {
logger.info { "Get page #$number for book: $book" }
logger.debug { "Get page #$number for book: $book" }
if (book.media.status != Media.Status.READY) {
logger.warn { "Book media is not ready, cannot get pages" }
@ -143,4 +150,40 @@ class BookAnalyzer(
return supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.book.path, fileName)
}
/**
* Will hash the first and last pages of the given book.
* The number of pages hashed from start/end is configurable.
*
* See [org.gotson.komga.infrastructure.configuration.KomgaProperties.pageHashing]
*/
fun hashPages(book: BookWithMedia): Media {
val hashedPages = book.media.pages.mapIndexed { index, bookPage ->
if (bookPage.fileHash.isBlank() && (index < pageHashing || index >= (book.media.pages.size - pageHashing))) {
val content = getPageContent(book, index + 1)
val hash = hashPage(bookPage, content)
bookPage.copy(fileHash = hash)
} else bookPage
}
return book.media.copy(pages = hashedPages)
}
/**
* Hash a single page, using the file content for hashing.
*
* For JPEG, the image is read/written to remove the metadata.
*/
fun hashPage(page: BookPage, content: ByteArray): String {
val bytes =
if (page.mediaType == ImageType.JPEG.mediaType) {
// JPEG could contain different EXIF data, reading and writing back the image will get rid of it
ByteArrayOutputStream().use { buffer ->
ImageIO.write(ImageIO.read(content.inputStream()), ImageType.JPEG.imageIOFormat, buffer)
buffer.toByteArray()
}
} else content
return hasher.computeHash(bytes.inputStream())
}
}

View file

@ -89,6 +89,12 @@ class BookLifecycle(
}
}
fun hashPagesAndPersist(book: Book) {
logger.info { "Hash and persist pages for book: $book" }
mediaRepository.update(bookAnalyzer.hashPages(BookWithMedia(book, mediaRepository.findById(book.id))))
}
fun generateThumbnailAndPersist(book: Book) {
logger.info { "Generate thumbnail and persist for book: $book" }
try {

View file

@ -25,6 +25,9 @@ class KomgaProperties {
var fileHashing: Boolean = true
@Positive
var pageHashing: Int = 3
var rememberMe = RememberMe()
@DurationUnit(ChronoUnit.SECONDS)

View file

@ -3,6 +3,7 @@ package org.gotson.komga.infrastructure.hash
import mu.KotlinLogging
import org.apache.commons.codec.digest.XXHash32
import org.springframework.stereotype.Component
import java.io.InputStream
import java.nio.file.Path
import kotlin.io.path.inputStream
@ -16,9 +17,14 @@ class Hasher {
fun computeHash(path: Path): String {
logger.info { "Hashing: $path" }
return computeHash(path.inputStream())
}
fun computeHash(stream: InputStream): String {
val hash = XXHash32(SEED)
path.inputStream().use {
stream.use {
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var len: Int

View file

@ -8,6 +8,7 @@ import org.gotson.komga.jooq.Tables
import org.gotson.komga.jooq.tables.records.MediaPageRecord
import org.gotson.komga.jooq.tables.records.MediaRecord
import org.jooq.DSLContext
import org.jooq.impl.DSL
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
@ -23,12 +24,32 @@ class MediaDao(
private val m = Tables.MEDIA
private val p = Tables.MEDIA_PAGE
private val f = Tables.MEDIA_FILE
private val b = Tables.BOOK
private val groupFields = arrayOf(*m.fields(), *p.fields())
override fun findById(bookId: String): Media =
find(dsl, bookId)
override fun findAllBookIdsByLibraryIdAndWithMissingPageHash(libraryId: String, pageHashing: Int): Collection<String> {
val pagesCount = DSL.count(p.BOOK_ID)
val hashedCount = DSL.sum(DSL.`when`(p.FILE_HASH.eq(""), 0).otherwise(1)).cast(Int::class.java)
val neededHash = pageHashing * 2
val neededHashForBook = DSL.`when`(pagesCount.lt(neededHash), pagesCount).otherwise(neededHash)
val r = dsl.select(b.ID)
.from(b)
.leftJoin(p).on(b.ID.eq(p.BOOK_ID))
.leftJoin(m).on(b.ID.eq(m.BOOK_ID))
.where(b.LIBRARY_ID.eq(libraryId))
.and(m.STATUS.eq(Media.Status.READY.name))
.groupBy(b.ID)
.having(hashedCount.lt(neededHashForBook))
.fetch()
return r.getValues(b.ID)
}
override fun getPagesSize(bookId: String): Int =
dsl.select(m.PAGE_COUNT)
.from(m)
@ -109,7 +130,8 @@ class MediaDao(
p.NUMBER,
p.WIDTH,
p.HEIGHT,
).values(null as String?, null, null, null, null, null),
p.FILE_HASH,
).values(null as String?, null, null, null, null, null, null),
).also { step ->
chunk.forEach { media ->
media.pages.forEachIndexed { index, page ->
@ -120,6 +142,7 @@ class MediaDao(
index,
page.dimension?.width,
page.dimension?.height,
page.fileHash,
)
}
}
@ -209,5 +232,6 @@ class MediaDao(
fileName = fileName,
mediaType = mediaType,
dimension = if (width != null && height != null) Dimension(width, height) else null,
fileHash = fileHash,
)
}

View file

@ -1,24 +1,40 @@
package org.gotson.komga.domain.service
import com.ninjasquad.springmockk.SpykBean
import io.mockk.every
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.BookPage
import org.gotson.komga.domain.model.BookWithMedia
import org.gotson.komga.domain.model.Media
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.infrastructure.configuration.KomgaProperties
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource
import org.junit.jupiter.params.provider.ValueSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.core.io.ClassPathResource
import org.springframework.test.context.junit.jupiter.SpringExtension
import java.nio.file.Path
import java.time.LocalDateTime
import kotlin.io.path.extension
import kotlin.io.path.inputStream
import kotlin.io.path.listDirectoryEntries
import kotlin.io.path.name
import kotlin.io.path.toPath
@ExtendWith(SpringExtension::class)
@SpringBootTest
class BookAnalyzerTest(
@Autowired private val bookAnalyzer: BookAnalyzer,
@Autowired private val komgaProperties: KomgaProperties,
) {
@SpykBean
private lateinit var bookAnalyzer: BookAnalyzer
@Test
fun `given rar4 archive when analyzing then media status is READY`() {
val file = ClassPathResource("archives/rar4.rar")
@ -118,4 +134,78 @@ class BookAnalyzerTest(
assertThat(media.status).isEqualTo(Media.Status.READY)
assertThat(media.pages).hasSize(2)
}
@Test
fun `given book with a single page when hashing then all pages are hashed`() {
val book = makeBook("book1")
val pages = listOf(BookPage("1.jpeg", "image/jpeg"))
val media = Media(Media.Status.READY, pages = pages)
every { bookAnalyzer.getPageContent(any(), any()) } returns ByteArray(1)
every { bookAnalyzer.hashPage(any(), any()) } returns "hashed"
val hashedMedia = bookAnalyzer.hashPages(BookWithMedia(book, media))
assertThat(hashedMedia.pages).hasSize(1)
assertThat(hashedMedia.pages.first().fileHash).isEqualTo("hashed")
}
@Test
fun `given book with more than 6 pages when hashing then only first and last 3 are hashed`() {
val book = makeBook("book1")
val pages = (1..30).map { BookPage("$it.jpeg", "image/jpeg") }
val media = Media(Media.Status.READY, pages = pages)
every { bookAnalyzer.getPageContent(any(), any()) } returns ByteArray(1)
every { bookAnalyzer.hashPage(any(), any()) } returns "hashed"
val hashedMedia = bookAnalyzer.hashPages(BookWithMedia(book, media))
assertThat(hashedMedia.pages).hasSize(30)
assertThat(hashedMedia.pages.take(komgaProperties.pageHashing).map { it.fileHash })
.hasSize(komgaProperties.pageHashing)
.containsOnly("hashed")
assertThat(hashedMedia.pages.takeLast(komgaProperties.pageHashing).map { it.fileHash })
.hasSize(komgaProperties.pageHashing)
.containsOnly("hashed")
assertThat(hashedMedia.pages.drop(komgaProperties.pageHashing).dropLast(komgaProperties.pageHashing).map { it.fileHash })
.hasSize(30 - (komgaProperties.pageHashing * 2))
.containsOnly("")
}
@Test
fun `given book with already hashed pages when hashing then no hashing is done`() {
val book = makeBook("book1")
val pages = (1..30).map { BookPage("$it.jpeg", "image/jpeg", fileHash = "hashed") }
val media = Media(Media.Status.READY, pages = pages)
val hashedMedia = bookAnalyzer.hashPages(BookWithMedia(book, media))
verify(exactly = 0) { bookAnalyzer.getPageContent(any(), any()) }
verify(exactly = 0) { bookAnalyzer.hashPage(any(), any()) }
assertThat(hashedMedia.pages.map { it.fileHash })
.hasSize(30)
.containsOnly("hashed")
}
@ParameterizedTest
@MethodSource("provideDirectoriesForPageHashing")
fun `given 2 exact pages when hashing then hashes are the same`(directory: Path) {
val files = directory.listDirectoryEntries()
assertThat(files).hasSize(2)
val mediaType = "image/${directory.fileName.extension}"
val hashes = files.map {
bookAnalyzer.hashPage(BookPage(it.name, mediaType = mediaType), it.inputStream().readBytes())
}
assertThat(hashes.first()).isEqualTo(hashes.last())
}
companion object {
@JvmStatic
fun provideDirectoriesForPageHashing() = ClassPathResource("hashpage").uri.toPath().listDirectoryEntries()
}
}

View file

@ -321,7 +321,7 @@ class LibraryContentLifecycleTest(
libraryContentLifecycle.scanRootFolder(library)
every { mockAnalyzer.analyze(any()) } returns Media(status = Media.Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg")), bookId = book1.id)
every { mockHasher.computeHash(any()) }.returnsMany("abc", "def")
every { mockHasher.computeHash(any<Path>()) }.returnsMany("abc", "def")
bookRepository.findAll().map {
bookLifecycle.analyzeAndPersist(it)
@ -334,7 +334,7 @@ class LibraryContentLifecycleTest(
// then
verify(exactly = 2) { mockScanner.scanRootFolder(any()) }
verify(exactly = 1) { mockAnalyzer.analyze(any()) }
verify(exactly = 1) { mockHasher.computeHash(any()) }
verify(exactly = 1) { mockHasher.computeHash(any<Path>()) }
bookRepository.findAll().first().let { book ->
assertThat(book.lastModifiedDate).isNotEqualTo(book.createdDate)
@ -489,7 +489,7 @@ class LibraryContentLifecycleTest(
mediaRepository.update(mediaRepository.findById(it.id).copy(status = Media.Status.READY))
}
every { mockHasher.computeHash(any()) } returns "sameHash"
every { mockHasher.computeHash(any<Path>()) } returns "sameHash"
libraryContentLifecycle.scanRootFolder(library) // deletion
@ -582,13 +582,13 @@ class LibraryContentLifecycleTest(
bookLifecycle.addThumbnailForBook(ThumbnailBook(url = URL("file:/sidecar"), type = ThumbnailBook.Type.SIDECAR, bookId = book.id), MarkSelectedPreference.NO)
}
every { mockHasher.computeHash(any()) } returns "sameHash"
every { mockHasher.computeHash(any<Path>()) } returns "sameHash"
// when
libraryContentLifecycle.scanRootFolder(library) // rename
// then
verify(exactly = 1) { mockHasher.computeHash(any()) }
verify(exactly = 1) { mockHasher.computeHash(any<Path>()) }
val allSeries = seriesRepository.findAll()
val allBooks = bookRepository.findAll().sortedBy { it.number }
@ -628,13 +628,13 @@ class LibraryContentLifecycleTest(
bookLifecycle.addThumbnailForBook(ThumbnailBook(url = URL("file:/sidecar"), type = ThumbnailBook.Type.SIDECAR, bookId = book.id), MarkSelectedPreference.NO)
}
every { mockHasher.computeHash(any()) } returns "sameHash"
every { mockHasher.computeHash(any<Path>()) } returns "sameHash"
// when
libraryContentLifecycle.scanRootFolder(library) // rename
// then
verify(exactly = 1) { mockHasher.computeHash(any()) }
verify(exactly = 1) { mockHasher.computeHash(any<Path>()) }
val allSeries = seriesRepository.findAll()
val allBooks = bookRepository.findAll().sortedBy { it.number }
@ -671,13 +671,13 @@ class LibraryContentLifecycleTest(
bookLifecycle.markReadProgressCompleted(it.id, user)
}
every { mockHasher.computeHash(any()) } returns "sameHash"
every { mockHasher.computeHash(any<Path>()) } returns "sameHash"
// when
libraryContentLifecycle.scanRootFolder(library) // rename
// then
verify(exactly = 1) { mockHasher.computeHash(any()) }
verify(exactly = 1) { mockHasher.computeHash(any<Path>()) }
val allSeries = seriesRepository.findAll()
val allBooks = bookRepository.findAll().sortedBy { it.number }
@ -714,13 +714,13 @@ class LibraryContentLifecycleTest(
readListLifecycle.addReadList(ReadList("read list", bookIds = listOf(it.id).toIndexedMap()))
}
every { mockHasher.computeHash(any()) } returns "sameHash"
every { mockHasher.computeHash(any<Path>()) } returns "sameHash"
// when
libraryContentLifecycle.scanRootFolder(library) // rename
// then
verify(exactly = 1) { mockHasher.computeHash(any()) }
verify(exactly = 1) { mockHasher.computeHash(any<Path>()) }
val allSeries = seriesRepository.findAll()
val allBooks = bookRepository.findAll().sortedBy { it.number }
@ -764,13 +764,13 @@ class LibraryContentLifecycleTest(
)
}
every { mockHasher.computeHash(any()) } returns "sameHash"
every { mockHasher.computeHash(any<Path>()) } returns "sameHash"
// when
libraryContentLifecycle.scanRootFolder(library) // rename
// then
verify(exactly = 1) { mockHasher.computeHash(any()) }
verify(exactly = 1) { mockHasher.computeHash(any<Path>()) }
verify(exactly = 0) { mockTaskReceiver.refreshBookMetadata(bookRenamed.id, setOf(BookMetadataPatchCapability.TITLE)) }
val allSeries = seriesRepository.findAll()
@ -808,13 +808,13 @@ class LibraryContentLifecycleTest(
bookRepository.update(it.copy(fileHash = "sameHash"))
}
every { mockHasher.computeHash(any()) } returns "sameHash"
every { mockHasher.computeHash(any<Path>()) } returns "sameHash"
// when
libraryContentLifecycle.scanRootFolder(library) // rename
// then
verify(exactly = 1) { mockHasher.computeHash(any()) }
verify(exactly = 1) { mockHasher.computeHash(any<Path>()) }
verify(exactly = 1) { mockTaskReceiver.refreshBookMetadata(bookRenamed.id, setOf(BookMetadataPatchCapability.TITLE)) }
val allSeries = seriesRepository.findAll()
@ -851,13 +851,13 @@ class LibraryContentLifecycleTest(
mediaRepository.findById(book.id).let { mediaRepository.update(it.copy(status = Media.Status.READY)) }
}
every { mockHasher.computeHash(any()) } returns "sameHash"
every { mockHasher.computeHash(any<Path>()) } returns "sameHash"
// when
libraryContentLifecycle.scanRootFolder(library) // rename
// then
verify(exactly = 1) { mockHasher.computeHash(any()) }
verify(exactly = 1) { mockHasher.computeHash(any<Path>()) }
val allSeries = seriesRepository.findAll()
val allBooks = bookRepository.findAll().sortedBy { it.number }
@ -903,13 +903,13 @@ class LibraryContentLifecycleTest(
mediaRepository.findById(book.id).let { mediaRepository.update(it.copy(status = Media.Status.READY)) }
}
every { mockHasher.computeHash(any()) } returns "sameHash"
every { mockHasher.computeHash(any<Path>()) } returns "sameHash"
// when
libraryContentLifecycle.scanRootFolder(library) // rename
// then
verify(exactly = 1) { mockHasher.computeHash(any()) }
verify(exactly = 1) { mockHasher.computeHash(any<Path>()) }
val allSeries = seriesRepository.findAll()
val allBooks = bookRepository.findAll().sortedBy { it.number }
@ -962,7 +962,7 @@ class LibraryContentLifecycleTest(
mediaRepository.update(mediaRepository.findById(it.id).copy(status = Media.Status.READY))
}
every { mockHasher.computeHash(any()) } returns "sameHash"
every { mockHasher.computeHash(any<Path>()) } returns "sameHash"
// when
libraryContentLifecycle.scanRootFolder(library) // rename
@ -1016,7 +1016,7 @@ class LibraryContentLifecycleTest(
bookLifecycle.markReadProgressCompleted(it.id, user)
}
every { mockHasher.computeHash(any()) } returns "sameHash"
every { mockHasher.computeHash(any<Path>()) } returns "sameHash"
// when
libraryContentLifecycle.scanRootFolder(library) // rename
@ -1072,7 +1072,7 @@ class LibraryContentLifecycleTest(
readListLifecycle.addReadList(ReadList("read list", bookIds = listOf(it.id).toIndexedMap()))
}
every { mockHasher.computeHash(any()) } returns "sameHash"
every { mockHasher.computeHash(any<Path>()) } returns "sameHash"
// when
libraryContentLifecycle.scanRootFolder(library) // rename
@ -1134,7 +1134,7 @@ class LibraryContentLifecycleTest(
)
}
every { mockHasher.computeHash(any()) } returns "sameHash"
every { mockHasher.computeHash(any<Path>()) } returns "sameHash"
// when
libraryContentLifecycle.scanRootFolder(library) // rename
@ -1192,7 +1192,7 @@ class LibraryContentLifecycleTest(
bookRepository.update(it.copy(fileHash = "sameHash"))
}
every { mockHasher.computeHash(any()) } returns "sameHash"
every { mockHasher.computeHash(any<Path>()) } returns "sameHash"
// when
libraryContentLifecycle.scanRootFolder(library) // rename
@ -1251,7 +1251,7 @@ class LibraryContentLifecycleTest(
libraryContentLifecycle.scanRootFolder(library) // rename
// then
verify(exactly = 2) { mockHasher.computeHash(any()) }
verify(exactly = 2) { mockHasher.computeHash(any<Path>()) }
val allSeries = seriesRepository.findAll()
val allBooks = bookRepository.findAll().sortedBy { it.number }

View file

@ -10,9 +10,11 @@ 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.configuration.KomgaProperties
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
@ -27,18 +29,19 @@ class MediaDaoTest(
@Autowired private val bookRepository: BookRepository,
@Autowired private val seriesRepository: SeriesRepository,
@Autowired private val libraryRepository: LibraryRepository,
@Autowired private val komgaProperties: KomgaProperties,
) {
private val library = makeLibrary()
private val series = makeSeries("Series")
private val book = makeBook("Book")
private val series = makeSeries("Series", libraryId = library.id)
private val book = makeBook("Book", libraryId = library.id, seriesId = series.id)
@BeforeAll
fun setup() {
libraryRepository.insert(library)
seriesRepository.insert(series.copy(libraryId = library.id))
seriesRepository.insert(series)
bookRepository.insert(book.copy(libraryId = library.id, seriesId = series.id))
bookRepository.insert(book)
}
@AfterEach
@ -183,4 +186,110 @@ class MediaDaoTest(
assertThat(found).isInstanceOf(Exception::class.java)
}
@Nested
inner class MissingPageHash {
@Test
fun `given media with single page not hashed when finding for missing page hash then it is returned`() {
val media = Media(
status = Media.Status.READY,
pages = listOf(
BookPage(
fileName = "1.jpg",
mediaType = "image/jpeg",
),
),
bookId = book.id,
)
mediaDao.insert(media)
val found = mediaDao.findAllBookIdsByLibraryIdAndWithMissingPageHash(book.libraryId, komgaProperties.pageHashing)
assertThat(found)
.hasSize(1)
.containsOnly(book.id)
}
@Test
fun `given media with no pages hashed when finding for missing page hash then it is returned`() {
val media = Media(
status = Media.Status.READY,
pages = (1..12).map {
BookPage(
fileName = "$it.jpg",
mediaType = "image/jpeg",
)
},
bookId = book.id,
)
mediaDao.insert(media)
val found = mediaDao.findAllBookIdsByLibraryIdAndWithMissingPageHash(book.libraryId, komgaProperties.pageHashing)
assertThat(found)
.hasSize(1)
.containsOnly(book.id)
}
@Test
fun `given media with single page hashed when finding for missing page hash then it is not returned`() {
val media = Media(
status = Media.Status.READY,
pages = listOf(
BookPage(
fileName = "1.jpg",
mediaType = "image/jpeg",
fileHash = "hashed",
),
),
bookId = book.id,
)
mediaDao.insert(media)
val found = mediaDao.findAllBookIdsByLibraryIdAndWithMissingPageHash(book.libraryId, komgaProperties.pageHashing)
assertThat(found).isEmpty()
}
@Test
fun `given media with required pages hashed when finding for missing page hash then it is not returned`() {
val media = Media(
status = Media.Status.READY,
pages = (1..12).map {
BookPage(
fileName = "$it.jpg",
mediaType = "image/jpeg",
fileHash = if (it <= 3 || it >= 9) "hashed" else "",
)
},
bookId = book.id,
)
mediaDao.insert(media)
val found = mediaDao.findAllBookIdsByLibraryIdAndWithMissingPageHash(book.libraryId, komgaProperties.pageHashing)
assertThat(found).isEmpty()
}
@Test
fun `given media with more pages hashed than required when finding for missing page hash then it is not returned`() {
val media = Media(
status = Media.Status.READY,
pages = (1..12).map {
BookPage(
fileName = "$it.jpg",
mediaType = "image/jpeg",
fileHash = "hashed",
)
},
bookId = book.id,
)
mediaDao.insert(media)
val found = mediaDao.findAllBookIdsByLibraryIdAndWithMissingPageHash(book.libraryId, komgaProperties.pageHashing)
assertThat(found).isEmpty()
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB