feat: hash pages to detect duplicates
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE MEDIA_PAGE
|
||||
ADD COLUMN FILE_HASH varchar NOT NULL DEFAULT '';
|
||||
|
|
@ -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')"
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@ data class BookPage(
|
|||
val fileName: String,
|
||||
val mediaType: String,
|
||||
val dimension: Dimension? = null,
|
||||
val fileHash: String = "",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>>
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ class KomgaProperties {
|
|||
|
||||
var fileHashing: Boolean = true
|
||||
|
||||
@Positive
|
||||
var pageHashing: Int = 3
|
||||
|
||||
var rememberMe = RememberMe()
|
||||
|
||||
@DurationUnit(ChronoUnit.SECONDS)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
komga/src/test/resources/hashpage/dd.png/1.png
Executable file
|
After Width: | Height: | Size: 129 KiB |
BIN
komga/src/test/resources/hashpage/dd.png/2.png
Executable file
|
After Width: | Height: | Size: 129 KiB |
BIN
komga/src/test/resources/hashpage/e-drq.webp/1.webp
Executable file
|
After Width: | Height: | Size: 198 KiB |
BIN
komga/src/test/resources/hashpage/e-drq.webp/2.webp
Executable file
|
After Width: | Height: | Size: 198 KiB |
BIN
komga/src/test/resources/hashpage/e-sou.jpeg/1.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
komga/src/test/resources/hashpage/e-sou.jpeg/2.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
komga/src/test/resources/hashpage/e-sou.webp/1.webp
Executable file
|
After Width: | Height: | Size: 457 KiB |
BIN
komga/src/test/resources/hashpage/e-sou.webp/2.webp
Executable file
|
After Width: | Height: | Size: 457 KiB |
BIN
komga/src/test/resources/hashpage/e-z.jpeg/1.jpg
Normal file
|
After Width: | Height: | Size: 733 KiB |
BIN
komga/src/test/resources/hashpage/e-z.jpeg/2.jpg
Normal file
|
After Width: | Height: | Size: 733 KiB |
BIN
komga/src/test/resources/hashpage/m-d.jpeg/1.jpg
Executable file
|
After Width: | Height: | Size: 117 KiB |
BIN
komga/src/test/resources/hashpage/m-d.jpeg/2.jpg
Executable file
|
After Width: | Height: | Size: 117 KiB |
BIN
komga/src/test/resources/hashpage/tg.png/1.png
Executable file
|
After Width: | Height: | Size: 52 KiB |
BIN
komga/src/test/resources/hashpage/tg.png/2.png
Executable file
|
After Width: | Height: | Size: 52 KiB |
BIN
komga/src/test/resources/hashpage/tr.gif/1.gif
Executable file
|
After Width: | Height: | Size: 78 KiB |
BIN
komga/src/test/resources/hashpage/tr.gif/2.gif
Executable file
|
After Width: | Height: | Size: 78 KiB |
BIN
komga/src/test/resources/hashpage/xt.png/1.png
Executable file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
komga/src/test/resources/hashpage/xt.png/2.png
Executable file
|
After Width: | Height: | Size: 1.5 MiB |