diff --git a/komga/build.gradle.kts b/komga/build.gradle.kts index e355d4379..3905b7d0a 100644 --- a/komga/build.gradle.kts +++ b/komga/build.gradle.kts @@ -129,12 +129,12 @@ tasks { group = "web" workingDir("$rootDir/komga-webui") commandLine( - if (Os.isFamily(Os.FAMILY_WINDOWS)) { - "npm.cmd" - } else { - "npm" - }, - "install" + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + "npm.cmd" + } else { + "npm" + }, + "install" ) } @@ -143,13 +143,13 @@ tasks { dependsOn("npmInstall") workingDir("$rootDir/komga-webui") commandLine( - if (Os.isFamily(Os.FAMILY_WINDOWS)) { - "npm.cmd" - } else { - "npm" - }, - "run", - "build" + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + "npm.cmd" + } else { + "npm" + }, + "run", + "build" ) } diff --git a/komga/schema/ComicInfo_v1.xsd b/komga/schema/ComicInfo_v1.xsd new file mode 100644 index 000000000..58082c510 --- /dev/null +++ b/komga/schema/ComicInfo_v1.xsd @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/komga/schema/ComicInfo_v2.xsd b/komga/schema/ComicInfo_v2.xsd new file mode 100644 index 000000000..cb0fc594e --- /dev/null +++ b/komga/schema/ComicInfo_v2.xsd @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/komga/src/main/kotlin/org/gotson/komga/application/service/AsyncOrchestrator.kt b/komga/src/main/kotlin/org/gotson/komga/application/service/AsyncOrchestrator.kt index e0e4ba416..11a23f68b 100644 --- a/komga/src/main/kotlin/org/gotson/komga/application/service/AsyncOrchestrator.kt +++ b/komga/src/main/kotlin/org/gotson/komga/application/service/AsyncOrchestrator.kt @@ -4,8 +4,10 @@ import mu.KotlinLogging import org.apache.commons.lang3.time.DurationFormatUtils import org.gotson.komga.domain.model.Book import org.gotson.komga.domain.model.Library +import org.gotson.komga.domain.model.Series 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.domain.service.LibraryScanner import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Service @@ -19,7 +21,9 @@ class AsyncOrchestrator( private val libraryScanner: LibraryScanner, private val libraryRepository: LibraryRepository, private val bookRepository: BookRepository, - private val bookLifecycle: BookLifecycle + private val bookLifecycle: BookLifecycle, + private val seriesRepository: SeriesRepository, + private val metadataLifecycle: MetadataLifecycle ) { @Async("periodicScanTaskExecutor") @@ -75,4 +79,12 @@ class AsyncOrchestrator( loadedBooks.map { bookLifecycle.analyzeAndPersist(it) } } + + @Async("reRefreshMetadataTaskExecutor") + @Transactional + fun refreshBooksMetadata(books: List) { + bookRepository + .findAllById(books.map { it.id }) + .forEach { metadataLifecycle.refreshMetadata(it) } + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/application/service/MetadataLifecycle.kt b/komga/src/main/kotlin/org/gotson/komga/application/service/MetadataLifecycle.kt new file mode 100644 index 000000000..ec72183cf --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/application/service/MetadataLifecycle.kt @@ -0,0 +1,48 @@ +package org.gotson.komga.application.service + +import mu.KotlinLogging +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.persistence.BookRepository +import org.gotson.komga.domain.persistence.SeriesRepository +import org.gotson.komga.domain.service.MetadataApplier +import org.gotson.komga.infrastructure.metadata.comicinfo.ComicInfoProvider +import org.springframework.data.repository.findByIdOrNull +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +private val logger = KotlinLogging.logger {} + +@Service +class MetadataLifecycle( + private val comicInfoProvider: ComicInfoProvider, + private val metadataApplier: MetadataApplier, + private val bookRepository: BookRepository, + private val seriesRepository: SeriesRepository +) { + + @Transactional + @Async("refreshMetadataTaskExecutor") + fun refreshMetadata(book: Book) { + logger.info { "Refresh metadata for book: $book" } + val loadedBook = bookRepository.findByIdOrNull(book.id) + + loadedBook?.let { b -> + val patch = comicInfoProvider.getBookMetadataFromBook(b) + + patch?.let { + metadataApplier.apply(it, b) + bookRepository.save(b) + } + + val seriesPatch = comicInfoProvider.getSeriesMetadataFromBook(b) + + seriesPatch?.let { + metadataApplier.apply(it, b.series) + seriesRepository.save(b.series) + } + } + } + +} diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Author.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Author.kt index f21cc16ff..02baf7d2b 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/Author.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Author.kt @@ -27,4 +27,5 @@ class Author { field = value.trim().toLowerCase() } + override fun toString(): String = "Author($name, $role)" } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt new file mode 100644 index 000000000..dc4117c13 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/BookMetadataPatch.kt @@ -0,0 +1,15 @@ +package org.gotson.komga.domain.model + +import java.time.LocalDate + +class BookMetadataPatch( + val title: String?, + val summary: String?, + val number: String?, + val numberSort: Float?, + val readingDirection: BookMetadata.ReadingDirection?, + val publisher: String?, + val ageRating: Int?, + val releaseDate: LocalDate?, + val authors: List? +) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt index 284519cd1..8e7cfdcff 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/Media.kt @@ -26,21 +26,23 @@ private val natSortComparator: Comparator = CaseInsensitiveSimpleNatural @Cacheable @Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "cache.media") class Media( - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false) - var status: Status = Status.UNKNOWN, + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + var status: Status = Status.UNKNOWN, - @Column(name = "media_type") - var mediaType: String? = null, + @Column(name = "media_type") + var mediaType: String? = null, - @Column(name = "thumbnail") - @Lob - var thumbnail: ByteArray? = null, + @Column(name = "thumbnail") + @Lob + var thumbnail: ByteArray? = null, - pages: Iterable = emptyList(), + pages: Iterable = emptyList(), - @Column(name = "comment") - var comment: String? = null + files: Iterable = emptyList(), + + @Column(name = "comment") + var comment: String? = null ) : AuditableEntity() { @Id @GeneratedValue @@ -60,6 +62,17 @@ class Media( _pages.addAll(value.sortedWith(compareBy(natSortComparator) { it.fileName })) } + @ElementCollection(fetch = FetchType.LAZY) + @CollectionTable(name = "media_file", joinColumns = [JoinColumn(name = "media_id")]) + @Column(name = "files") + private var _files: MutableList = mutableListOf() + + var files: List + get() = _files.toList() + set(value) { + _files.clear() + _files.addAll(value) + } fun reset() { status = Status.UNKNOWN @@ -67,10 +80,12 @@ class Media( thumbnail = null comment = null _pages.clear() + _files.clear() } init { this.pages = pages.toList() + this.files = files.toList() } enum class Status { diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadataPatch.kt b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadataPatch.kt new file mode 100644 index 000000000..ee13cf50b --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/model/SeriesMetadataPatch.kt @@ -0,0 +1,7 @@ +package org.gotson.komga.domain.model + +class SeriesMetadataPatch( + val title: String?, + val titleSort: String?, + val status: SeriesMetadata.Status? +) diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt index 4b4aeab98..7b602b339 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/BookAnalyzer.kt @@ -72,7 +72,9 @@ class BookAnalyzer( logger.info { "Trying to generate cover for book: $book" } val thumbnail = generateThumbnail(book, mediaType, pages.first().fileName) - return Media(mediaType = mediaType, status = Media.Status.READY, pages = pages, thumbnail = thumbnail, comment = entriesErrorSummary) + val files = others.map { it.name } + + return Media(mediaType = mediaType, status = Media.Status.READY, pages = pages, files = files, thumbnail = thumbnail, comment = entriesErrorSummary) } @Throws(MediaNotReadyException::class) @@ -123,4 +125,18 @@ class BookAnalyzer( return supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.path(), book.media.pages[number - 1].fileName) } + + @Throws( + MediaNotReadyException::class + ) + fun getFileContent(book: Book, fileName: String): ByteArray { + logger.info { "Get file $fileName for book: $book" } + + if (book.media.status != Media.Status.READY) { + logger.warn { "Book media is not ready, cannot get files" } + throw MediaNotReadyException() + } + + return supportedMediaTypes.getValue(book.media.mediaType!!).getEntryStream(book.path(), fileName) + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryScanner.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryScanner.kt index d0b87ae10..915251da1 100644 --- a/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryScanner.kt +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/LibraryScanner.kt @@ -3,6 +3,7 @@ package org.gotson.komga.domain.service import mu.KotlinLogging import org.apache.commons.lang3.time.DurationFormatUtils import org.gotson.komga.application.service.BookLifecycle +import org.gotson.komga.application.service.MetadataLifecycle import org.gotson.komga.domain.model.Library import org.gotson.komga.domain.model.Media import org.gotson.komga.domain.persistence.BookRepository @@ -20,7 +21,8 @@ class LibraryScanner( private val fileSystemScanner: FileSystemScanner, private val seriesRepository: SeriesRepository, private val bookRepository: BookRepository, - private val bookLifecycle: BookLifecycle + private val bookLifecycle: BookLifecycle, + private val metadataLifecycle: MetadataLifecycle ) { @Transactional @@ -94,7 +96,10 @@ class LibraryScanner( }.also { logger.info { "Analyzed ${booksToAnalyze.size} books in ${DurationFormatUtils.formatDurationHMS(it)} (virtual: ${DurationFormatUtils.formatDurationHMS(sumOfTasksTime)})" } } + + logger.info { "Refresh metadata for all books analyzed" } + booksToAnalyze.forEach { + metadataLifecycle.refreshMetadata(it) + } } - - } diff --git a/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt new file mode 100644 index 000000000..6f5d99177 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/domain/service/MetadataApplier.kt @@ -0,0 +1,124 @@ +package org.gotson.komga.domain.service + +import mu.KotlinLogging +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookMetadataPatch +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.SeriesMetadataPatch +import org.springframework.stereotype.Service + +private val logger = KotlinLogging.logger {} + +@Service +class MetadataApplier { + + fun apply(patch: BookMetadataPatch, book: Book) { + logger.debug { "Apply metadata for book: $book" } + + with(book.metadata) { + patch.title?.let { + if (!titleLock) { + logger.debug { "Update title: $it" } + title = it + } else + logger.debug { "title is locked, skipping" } + } + + + patch.summary?.let { + if (!summaryLock) { + logger.debug { "Update summary: $it" } + summary = it + } else + logger.debug { "summary is locked, skipping" } + } + + patch.number?.let { + if (!numberLock) { + logger.debug { "Update number: $it" } + number = it + } else + logger.debug { "number is locked, skipping" } + } + + patch.numberSort?.let { + if (!numberSortLock) { + logger.debug { "Update numberSort: $it" } + numberSort = it + } else + logger.debug { "numberSort is locked, skipping" } + } + + patch.readingDirection?.let { + if (!readingDirectionLock) { + logger.debug { "Update readingDirection: $it" } + readingDirection = it + } else + logger.debug { "readingDirection is locked, skipping" } + } + + patch.releaseDate?.let { + if (!releaseDateLock) { + logger.debug { "Update releaseDate: $it" } + releaseDate = it + } else + logger.debug { "releaseDate is locked, skipping" } + } + + patch.ageRating?.let { + if (!ageRatingLock) { + logger.debug { "Update ageRating: $it" } + ageRating = it + } else + logger.debug { "ageRating is locked, skipping" } + } + + patch.publisher?.let { + if (!publisherLock) { + logger.debug { "Update publisher: $it" } + publisher = it + } else + logger.debug { "publisher is locked, skipping" } + } + + patch.authors?.let { + if (!authorsLock) { + logger.debug { "Update authors: $it" } + authors = it.toMutableList() + } else + logger.debug { "authors is locked, skipping" } + } + } + } + + fun apply(patch: SeriesMetadataPatch, series: Series) { + logger.debug { "Apply metadata for series: $series" } + + with(series.metadata) { + patch.title?.let { + if (!titleLock) { + logger.debug { "Update title: $it" } + title = it + } else + logger.debug { "title is locked, skipping" } + } + + patch.titleSort?.let { + if (!titleSortLock) { + logger.debug { "Update titleSort: $it" } + titleSort = it + } else + logger.debug { "titleSort is locked, skipping" } + } + + patch.status?.let { + if (!statusLock) { + logger.debug { "status number: $it" } + status = it + } else + logger.debug { "status is locked, skipping" } + } + } + } + +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/async/AsyncConfiguration.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/async/AsyncConfiguration.kt index 80aea1a14..abe17e01c 100644 --- a/komga/src/main/kotlin/org/gotson/komga/infrastructure/async/AsyncConfiguration.kt +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/async/AsyncConfiguration.kt @@ -40,4 +40,16 @@ class AsyncConfiguration( ThreadPoolTaskExecutor().apply { corePoolSize = 1 } + + @Bean("refreshMetadataTaskExecutor") + fun refreshMetadataTaskExecutor(): Executor = + ThreadPoolTaskExecutor().apply { + corePoolSize = 1 + } + + @Bean("reRefreshMetadataTaskExecutor") + fun reRefreshMetadataTaskExecutor(): Executor = + ThreadPoolTaskExecutor().apply { + corePoolSize = 1 + } } diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/BookMetadataProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/BookMetadataProvider.kt new file mode 100644 index 000000000..0434fca70 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/BookMetadataProvider.kt @@ -0,0 +1,8 @@ +package org.gotson.komga.infrastructure.metadata + +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookMetadataPatch + +interface BookMetadataProvider { + fun getBookMetadataFromBook(book: Book): BookMetadataPatch? +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/SeriesMetadataProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/SeriesMetadataProvider.kt new file mode 100644 index 000000000..9b3316ada --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/SeriesMetadataProvider.kt @@ -0,0 +1,9 @@ +package org.gotson.komga.infrastructure.metadata + +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.Series +import org.gotson.komga.domain.model.SeriesMetadataPatch + +interface SeriesMetadataProvider { + fun getSeriesMetadataFromBook(book: Book): SeriesMetadataPatch? +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProvider.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProvider.kt new file mode 100644 index 000000000..b1e6282e7 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProvider.kt @@ -0,0 +1,93 @@ +package org.gotson.komga.infrastructure.metadata.comicinfo + +import com.fasterxml.jackson.dataformat.xml.XmlMapper +import mu.KotlinLogging +import org.gotson.komga.domain.model.Author +import org.gotson.komga.domain.model.Book +import org.gotson.komga.domain.model.BookMetadata +import org.gotson.komga.domain.model.BookMetadataPatch +import org.gotson.komga.domain.model.SeriesMetadataPatch +import org.gotson.komga.domain.service.BookAnalyzer +import org.gotson.komga.infrastructure.metadata.BookMetadataProvider +import org.gotson.komga.infrastructure.metadata.SeriesMetadataProvider +import org.gotson.komga.infrastructure.metadata.comicinfo.dto.ComicInfo +import org.gotson.komga.infrastructure.metadata.comicinfo.dto.Manga +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import java.time.LocalDate + +private val logger = KotlinLogging.logger {} + +private const val COMIC_INFO = "ComicInfo.xml" + +@Service +class ComicInfoProvider( + @Autowired(required = false) private val mapper: XmlMapper = XmlMapper(), + private val bookAnalyzer: BookAnalyzer +) : BookMetadataProvider, SeriesMetadataProvider { + + override fun getBookMetadataFromBook(book: Book): BookMetadataPatch? { + getComicInfo(book)?.let { comicInfo -> + val releaseDate = comicInfo.year?.let { + LocalDate.of(comicInfo.year!!, comicInfo.month ?: 1, 1) + } + + val authors = mutableListOf() + comicInfo.writer?.let { authors += it.splitWithRole("writer") } + comicInfo.penciller?.let { authors += it.splitWithRole("penciller") } + comicInfo.inker?.let { authors += it.splitWithRole("inker") } + comicInfo.colorist?.let { authors += it.splitWithRole("colorist") } + comicInfo.letterer?.let { authors += it.splitWithRole("letterer") } + comicInfo.coverArtist?.let { authors += it.splitWithRole("cover") } + comicInfo.editor?.let { authors += it.splitWithRole("editor") } + + val readingDirection = when (comicInfo.manga) { + Manga.NO -> BookMetadata.ReadingDirection.LEFT_TO_RIGHT + Manga.YES_AND_RIGHT_TO_LEFT -> BookMetadata.ReadingDirection.RIGHT_TO_LEFT + else -> null + } + + return BookMetadataPatch( + comicInfo.title, + comicInfo.summary, + comicInfo.number, + comicInfo.number?.toFloatOrNull(), + readingDirection, + comicInfo.publisher, + comicInfo.ageRating?.ageRating, + releaseDate, + if (authors.isEmpty()) null else authors + ) + } + return null + } + + override fun getSeriesMetadataFromBook(book: Book): SeriesMetadataPatch? { + getComicInfo(book)?.let { comicInfo -> + return SeriesMetadataPatch( + comicInfo.series, + comicInfo.series, + null + ) + } + return null + } + + private fun getComicInfo(book: Book): ComicInfo? { + try { + if (book.media.files.none { it == COMIC_INFO }) { + logger.debug { "Book does not contain any $COMIC_INFO file: ${book.url}" } + return null + } + + val fileContent = bookAnalyzer.getFileContent(book, COMIC_INFO) + return mapper.readValue(fileContent, ComicInfo::class.java) + } catch (e: Exception) { + logger.error(e) { "Error while retrieving metadata from ComicInfo.xml" } + return null + } + } + + private fun String.splitWithRole(role: String) = + split(',').map { Author(it, role) } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/AgeRating.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/AgeRating.kt new file mode 100644 index 000000000..06b3b5ebc --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/AgeRating.kt @@ -0,0 +1,29 @@ +package org.gotson.komga.infrastructure.metadata.comicinfo.dto + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonIgnoreProperties + +enum class AgeRating(val value: String, val ageRating: Int? = null) { + UNKNOWN("Unknown"), + ADULTS_ONLY_18("Adults Only 18+", 18), + EARLY_CHILDHOOD("Early Childhood", 3), + EVERYONE("Everyone", 0), + EVERYONE_10("Everyone 10+", 10), + G("G", 0), + KIDS_TO_ADULTS("Kids to Adults", 6), + M("M", 17), + MA_15("MA 15+", 15), + MATURE_17("Mature 17+", 17), + PG("PG", 8), + R_18("R18+", 18), + RATING_PENDING("Rating Pending"), + TEEN("Teen", 13), + X_18("X18+", 18); + + companion object { + private val map = values().associateBy(AgeRating::value) + @JvmStatic + @JsonCreator + fun fromValue(value: String) = map[value] + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/ComicInfo.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/ComicInfo.kt new file mode 100644 index 000000000..aded05ca4 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/ComicInfo.kt @@ -0,0 +1,121 @@ +package org.gotson.komga.infrastructure.metadata.comicinfo.dto + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import javax.xml.bind.annotation.XmlAccessType +import javax.xml.bind.annotation.XmlAccessorType +import javax.xml.bind.annotation.XmlElement +import javax.xml.bind.annotation.XmlSchemaType +import javax.xml.bind.annotation.XmlType + +@JsonIgnoreProperties(ignoreUnknown = true) +class ComicInfo { + + @JsonProperty(value = "Title") + var title: String? = null + + @JsonProperty(value = "Series") + var series: String? = null + + @JsonProperty(value = "Number") + var number: String? = null + + @JsonProperty(value = "Count") + var count: Int? = null + + @JsonProperty(value = "Volume") + var volume: Int? = null + + @JsonProperty(value = "AlternateSeries") + var alternateSeries: String? = null + + @JsonProperty(value = "AlternateNumber") + var alternateNumber: String? = null + + @JsonProperty(value = "AlternateCount") + var alternateCount: Int? = null + + @JsonProperty(value = "Summary") + var summary: String? = null + + @JsonProperty(value = "Notes") + var notes: String? = null + + @JsonProperty(value = "Year") + var year: Int? = null + + @JsonProperty(value = "Month") + var month: Int? = null + + @JsonProperty(value = "Writer") + var writer: String? = null + + @JsonProperty(value = "Penciller") + var penciller: String? = null + + @JsonProperty(value = "Inker") + var inker: String? = null + + @JsonProperty(value = "Colorist") + var colorist: String? = null + + @JsonProperty(value = "Letterer") + var letterer: String? = null + + @JsonProperty(value = "CoverArtist") + var coverArtist: String? = null + + @JsonProperty(value = "Editor") + var editor: String? = null + + @JsonProperty(value = "Publisher") + var publisher: String? = null + + @JsonProperty(value = "Imprint") + var imprint: String? = null + + @JsonProperty(value = "Genre") + var genre: String? = null + + @JsonProperty(value = "Web") + var web: String? = null + + @JsonProperty(value = "PageCount") + var pageCount: Int? = null + + @JsonProperty(value = "LanguageISO") + var languageISO: String? = null + + @JsonProperty(value = "Format") + var format: String? = null + + @JsonProperty(value = "BlackAndWhite", defaultValue = "Unknown") + @XmlSchemaType(name = "string") + var blackAndWhite: YesNo? = null + + @JsonProperty(value = "Manga", defaultValue = "Unknown") + @XmlSchemaType(name = "string") + var manga: Manga? = null + + @JsonProperty(value = "Characters") + var characters: String? = null + + @JsonProperty(value = "Teams") + var teams: String? = null + + @JsonProperty(value = "Locations") + var locations: String? = null + + @JsonProperty(value = "ScanInformation") + var scanInformation: String? = null + + @JsonProperty(value = "StoryArc") + var storyArc: String? = null + + @JsonProperty(value = "SeriesGroup") + var seriesGroup: String? = null + + @JsonProperty(value = "AgeRating", defaultValue = "Unknown") + var ageRating: AgeRating? = null + +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/Manga.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/Manga.kt new file mode 100644 index 000000000..bb061c3fd --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/Manga.kt @@ -0,0 +1,19 @@ +package org.gotson.komga.infrastructure.metadata.comicinfo.dto + +import com.fasterxml.jackson.annotation.JsonCreator +import javax.xml.bind.annotation.XmlEnum +import javax.xml.bind.annotation.XmlType + +enum class Manga(private val value: String) { + UNKNOWN("Unknown"), + NO("No"), + YES("Yes"), + YES_AND_RIGHT_TO_LEFT("YesAndRightToLeft"); + + companion object { + private val map = values().associateBy(Manga::value) + @JvmStatic + @JsonCreator + fun fromValue(value: String) = map[value] + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/YesNo.kt b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/YesNo.kt new file mode 100644 index 000000000..ed0900dc4 --- /dev/null +++ b/komga/src/main/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/YesNo.kt @@ -0,0 +1,18 @@ +package org.gotson.komga.infrastructure.metadata.comicinfo.dto + +import com.fasterxml.jackson.annotation.JsonCreator +import javax.xml.bind.annotation.XmlEnum +import javax.xml.bind.annotation.XmlType + +enum class YesNo(val value: String) { + UNKNOWN("Unknown"), + NO("No"), + YES("Yes"); + + companion object { + private val map = values().associateBy(YesNo::value) + @JvmStatic + @JsonCreator + fun fromValue(value: String) = map[value] + } +} diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt index 8b554eb1c..dbbfcbf61 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/BookController.kt @@ -341,6 +341,19 @@ class BookController( } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } + @PostMapping("api/v1/books/{bookId}/metadata/refresh") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun refreshMetadata(@PathVariable bookId: Long) { + bookRepository.findByIdOrNull(bookId)?.let { book -> + try { + asyncOrchestrator.refreshBooksMetadata(listOf(book)) + } catch (e: RejectedExecutionException) { + throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Another metadata refresh task is already running") + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + @PatchMapping("api/v1/books/{bookId}/metadata") @PreAuthorize("hasRole('ADMIN')") fun updateMetadata( diff --git a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt index c6fbba7c7..051258d37 100644 --- a/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt +++ b/komga/src/main/kotlin/org/gotson/komga/interfaces/rest/LibraryController.kt @@ -114,6 +114,19 @@ class LibraryController( } } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } + + @PostMapping("{libraryId}/metadata/refresh") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun refreshMetadata(@PathVariable libraryId: Long) { + libraryRepository.findByIdOrNull(libraryId)?.let { library -> + try { + asyncOrchestrator.refreshBooksMetadata(bookRepository.findBySeriesLibraryIn(listOf(library))) + } catch (e: RejectedExecutionException) { + throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Another metadata refresh task is already running") + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } } data class LibraryCreationDto( 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 001508533..d14bdc3bb 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 @@ -219,6 +219,19 @@ class SeriesController( } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) } + @PostMapping("{seriesId}/metadata/refresh") + @PreAuthorize("hasRole('ADMIN')") + @ResponseStatus(HttpStatus.ACCEPTED) + fun refreshMetadata(@PathVariable seriesId: Long) { + seriesRepository.findByIdOrNull(seriesId)?.let { series -> + try { + asyncOrchestrator.refreshBooksMetadata(series.books) + } catch (e: RejectedExecutionException) { + throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Another metadata refresh task is already running") + } + } ?: throw ResponseStatusException(HttpStatus.NOT_FOUND) + } + @PatchMapping("{seriesId}/metadata") @PreAuthorize("hasRole('ADMIN')") fun updateMetadata( diff --git a/komga/src/main/resources/db/migration/V20200402103754__media_files.sql b/komga/src/main/resources/db/migration/V20200402103754__media_files.sql new file mode 100644 index 000000000..87f026a1c --- /dev/null +++ b/komga/src/main/resources/db/migration/V20200402103754__media_files.sql @@ -0,0 +1,8 @@ +create table media_file +( + media_id bigint not null, + files varchar +); + +alter table media_file + add constraint fk_media_file_media_media_id foreign key (media_id) references media(id); diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProviderTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProviderTest.kt new file mode 100644 index 000000000..bd4e3c84f --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/ComicInfoProviderTest.kt @@ -0,0 +1,170 @@ +package org.gotson.komga.infrastructure.metadata.comicinfo + +import com.fasterxml.jackson.dataformat.xml.XmlMapper +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.gotson.komga.domain.model.Media +import org.gotson.komga.domain.model.makeBook +import org.gotson.komga.domain.service.BookAnalyzer +import org.gotson.komga.infrastructure.metadata.comicinfo.dto.AgeRating +import org.gotson.komga.infrastructure.metadata.comicinfo.dto.ComicInfo +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.time.LocalDate + +class ComicInfoProviderTest { + + private val mockMapper = mockk() + private val mockAnalyzer = mockk().also { + every { it.getFileContent(any(), "ComicInfo.xml") } returns ByteArray(0) + } + + private val comicInfoProvider = ComicInfoProvider(mockMapper, mockAnalyzer) + + private val book = makeBook("book").also { + it.media = Media( + status = Media.Status.READY, + mediaType = "application/zip", + files = listOf("ComicInfo.xml") + ) + } + + @Nested + inner class Book { + + @Test + fun `given comicInfo when getting book metadata then metadata patch is valid`() { + val comicInfo = ComicInfo().apply { + title = "title" + summary = "summary" + number = "010" + publisher = "publisher" + ageRating = AgeRating.MA_15 + year = 2020 + month = 2 + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(book) + + with(patch!!) { + assertThat(title).isEqualTo("title") + assertThat(summary).isEqualTo("summary") + assertThat(number).isEqualTo("010") + assertThat(numberSort).isEqualTo(10F) + assertThat(publisher).isEqualTo("publisher") + assertThat(ageRating).isEqualTo(15) + assertThat(releaseDate).isEqualTo(LocalDate.of(2020, 2, 1)) + assertThat(readingDirection).isNull() + } + } + + @Test + fun `given comicInfo without year when getting book metadata then release date is null`() { + val comicInfo = ComicInfo().apply { + month = 2 + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(book) + + with(patch!!) { + assertThat(releaseDate).isNull() + } + } + + @Test + fun `given comicInfo with year but without month when getting book metadata then release date is set`() { + val comicInfo = ComicInfo().apply { + year = 2020 + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(book) + + with(patch!!) { + assertThat(releaseDate).isEqualTo(LocalDate.of(2020, 1, 1)) + } + } + + @Test + fun `given comicInfo with authors when getting book metadata then authors are set`() { + val comicInfo = ComicInfo().apply { + writer = "writer" + penciller = "penciller" + inker = "inker" + colorist = "colorist" + editor = "editor" + letterer = "letterer" + coverArtist = "coverArtist" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(book) + + with(patch!!) { + assertThat(authors).hasSize(7) + assertThat(authors?.map { it.name }).containsExactlyInAnyOrder("writer", "penciller", "inker", "colorist", "editor", "letterer", "coverArtist") + assertThat(authors?.map { it.role }).containsExactlyInAnyOrder("writer", "penciller", "inker", "colorist", "editor", "letterer", "cover") + } + } + + @Test + fun `given comicInfo with multiple authors when getting book metadata then authors are set`() { + val comicInfo = ComicInfo().apply { + writer = "writer, writer2" + penciller = "penciller, penciller2" + inker = "inker, inker2" + colorist = "colorist, colorist2" + editor = "editor, editor2" + letterer = "letterer, letterer2" + coverArtist = "coverArtist, coverArtist2" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getBookMetadataFromBook(book) + + with(patch!!) { + assertThat(authors).hasSize(14) + assertThat(authors?.map { it.name }).containsExactlyInAnyOrder("writer", "penciller", "inker", "colorist", "editor", "letterer", "coverArtist", "writer2", "penciller2", "inker2", "colorist2", "editor2", "letterer2", "coverArtist2") + assertThat(authors?.map { it.role }?.distinct()).containsExactlyInAnyOrder("writer", "penciller", "inker", "colorist", "editor", "letterer", "cover") + } + } + + @Test + fun `given book without comicInfo file when getting book metadata then return null`() { + val book = makeBook("book").also { + it.media = Media(Media.Status.READY) + } + val patch = comicInfoProvider.getBookMetadataFromBook(book) + + assertThat(patch).isNull() + } + } + + @Nested + inner class Series { + @Test + fun `given comicInfo when getting series metadata then metadata patch is valid`() { + val comicInfo = ComicInfo().apply { + series = "series" + } + + every { mockMapper.readValue(any(), ComicInfo::class.java) } returns comicInfo + + val patch = comicInfoProvider.getSeriesMetadataFromBook(book) + + with(patch!!) { + assertThat(title).isEqualTo("series") + assertThat(titleSort).isEqualTo("series") + assertThat(status).isNull() + } + } + } +} diff --git a/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/ComicInfoTest.kt b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/ComicInfoTest.kt new file mode 100644 index 000000000..7b30d5086 --- /dev/null +++ b/komga/src/test/kotlin/org/gotson/komga/infrastructure/metadata/comicinfo/dto/ComicInfoTest.kt @@ -0,0 +1,49 @@ +package org.gotson.komga.infrastructure.metadata.comicinfo.dto + +import com.fasterxml.jackson.dataformat.xml.XmlMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.core.io.ClassPathResource +import org.springframework.core.io.ResourceLoader + +class ComicInfoTest{ + + @Test + fun `given valid xml file when deserializing then properties are available`() { + val file = ClassPathResource("comicinfo/ComicInfo.xml") + val mapper = XmlMapper() + val comicInfo = mapper.readValue(file.url, ComicInfo::class.java) + + with(comicInfo){ + assertThat(title).isEqualTo("v01 - Preludes & Nocturnes - 30th Anniversary Edition") + assertThat(series).isEqualTo("Sandman") + assertThat(web).isEqualTo("https://www.comixology.com/Sandman/digital-comic/727888") + assertThat(summary).startsWith("Neil Gaiman's seminal series") + assertThat(notes).isEqualTo("Scraped metadata from Comixology [CMXDB727888], [RELDATE:2018-10-30]") + assertThat(publisher).isEqualTo("DC") + assertThat(imprint).isEqualTo("Vertigo") + assertThat(genre).isEqualTo("Fantasy, Supernatural/Occult, Horror, Mature, Superhero, Mythology, Drama") + assertThat(pageCount).isEqualTo(237) + assertThat(languageISO).isEqualTo("en") + assertThat(scanInformation).isEqualTo("") + assertThat(ageRating).isEqualTo(AgeRating.MATURE_17) + assertThat(blackAndWhite).isEqualTo(YesNo.NO) + assertThat(manga).isEqualTo(Manga.NO) + } + } + + @Test + fun `given incorrect enum values when deserializing then it is ignored`() { + val file = ClassPathResource("comicinfo/InvalidEnumValues.xml") + val mapper = XmlMapper() + val comicInfo = mapper.readValue(file.url, ComicInfo::class.java) + + with(comicInfo){ + assertThat(ageRating).isNull() + assertThat(blackAndWhite).isNull() + assertThat(manga).isNull() + } + } +} diff --git a/komga/src/test/resources/comicinfo/ComicInfo.xml b/komga/src/test/resources/comicinfo/ComicInfo.xml new file mode 100644 index 000000000..3124538e1 --- /dev/null +++ b/komga/src/test/resources/comicinfo/ComicInfo.xml @@ -0,0 +1,25 @@ + + + v01 - Preludes & Nocturnes - 30th Anniversary Edition + Sandman + https://www.comixology.com/Sandman/digital-comic/727888 + Neil Gaiman's seminal series, THE SANDMAN, celebrates its 30th anniversary with an all-new edition of THE SANDMAN VOL. 1: PRELUDES & NOCTURNES! + + New York Times best-selling author Neil Gaiman's transcendent series THE SANDMAN is often hailed as the definitive Vertigo title and one of the finest achievements in graphic storytelling. Gaiman created an unforgettable tale of the forces that exist beyond life and death by weaving ancient mythology, folklore and fairy tales with his own distinct narrative vision. + + In PRELUDES & NOCTURNES, an occultist attempting to capture Death to bargain for eternal life traps her younger brother Dream instead. After his 70 year imprisonment and eventual escape, Dream, also known as Morpheus, goes on a quest for his lost objects of power. On his arduous journey Morpheus encounters Lucifer, John Constantine, and an all-powerful madman. + + This book also includes the story "The Sound of Her Wings," which introduces us to the pragmatic and perky goth girl Death. + + Collects THE SANDMAN #1-8. + Scraped metadata from Comixology [CMXDB727888], [RELDATE:2018-10-30] + DC + Vertigo + Fantasy, Supernatural/Occult, Horror, Mature, Superhero, Mythology, Drama + 237 + en + Mature 17+ + No + No + + diff --git a/komga/src/test/resources/comicinfo/InvalidEnumValues.xml b/komga/src/test/resources/comicinfo/InvalidEnumValues.xml new file mode 100644 index 000000000..c1195aead --- /dev/null +++ b/komga/src/test/resources/comicinfo/InvalidEnumValues.xml @@ -0,0 +1,6 @@ + + + Non existent + Non existent + Non existent +