feat: retrieve metadata from ComicInfo.xml

retrieve for both Book and Series
This commit is contained in:
Gauthier Roebroeck 2020-04-03 12:15:05 +08:00
parent cde2756960
commit af01d25ede
28 changed files with 1066 additions and 29 deletions

View file

@ -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"
)
}

View file

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema elementFormDefault="qualified" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="ComicInfo" nillable="true" type="ComicInfo" />
<xs:complexType name="ComicInfo">
<xs:sequence>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Title" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Series" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Number" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Count" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Volume" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="AlternateSeries" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="AlternateNumber" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="AlternateCount" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Summary" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Notes" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Year" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Month" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Writer" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Penciller" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Inker" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Colorist" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Letterer" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="CoverArtist" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Editor" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Publisher" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Imprint" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Genre" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Web" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="0" name="PageCount" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="LanguageISO" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Format" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="Unknown" name="BlackAndWhite" type="YesNo" />
<xs:element minOccurs="0" maxOccurs="1" default="Unknown" name="Manga" type="YesNo" />
<xs:element minOccurs="0" maxOccurs="1" name="Pages" type="ArrayOfComicPageInfo" />
</xs:sequence>
</xs:complexType>
<xs:simpleType name="YesNo">
<xs:restriction base="xs:string">
<xs:enumeration value="Unknown" />
<xs:enumeration value="No" />
<xs:enumeration value="Yes" />
</xs:restriction>
</xs:simpleType>
<xs:complexType name="ArrayOfComicPageInfo">
<xs:sequence>
<xs:element minOccurs="0" maxOccurs="unbounded" name="Page" nillable="true" type="ComicPageInfo" />
</xs:sequence>
</xs:complexType>
<xs:complexType name="ComicPageInfo">
<xs:attribute name="Image" type="xs:int" use="required" />
<xs:attribute default="Story" name="Type" type="ComicPageType" />
<xs:attribute default="false" name="DoublePage" type="xs:boolean" />
<xs:attribute default="0" name="ImageSize" type="xs:long" />
<xs:attribute default="" name="Key" type="xs:string" />
<xs:attribute default="-1" name="ImageWidth" type="xs:int" />
<xs:attribute default="-1" name="ImageHeight" type="xs:int" />
</xs:complexType>
<xs:simpleType name="ComicPageType">
<xs:list>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:enumeration value="FrontCover" />
<xs:enumeration value="InnerCover" />
<xs:enumeration value="Roundup" />
<xs:enumeration value="Story" />
<xs:enumeration value="Advertisment" />
<xs:enumeration value="Editorial" />
<xs:enumeration value="Letters" />
<xs:enumeration value="Preview" />
<xs:enumeration value="BackCover" />
<xs:enumeration value="Other" />
<xs:enumeration value="Deleted" />
</xs:restriction>
</xs:simpleType>
</xs:list>
</xs:simpleType>
</xs:schema>

View file

@ -0,0 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema elementFormDefault="qualified" xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="ComicInfo" nillable="true" type="ComicInfo" />
<xs:complexType name="ComicInfo">
<xs:sequence>
<xs:element minOccurs="0" maxOccurs="1" default="" name="Title" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Series" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Number" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Count" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Volume" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="AlternateSeries" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="AlternateNumber" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="AlternateCount" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Summary" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Notes" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Year" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="-1" name="Month" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Writer" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Penciller" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Inker" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Colorist" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Letterer" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="CoverArtist" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Editor" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Publisher" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Imprint" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Genre" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Web" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="0" name="PageCount" type="xs:int" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="LanguageISO" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Format" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="Unknown" name="BlackAndWhite" type="YesNo" />
<xs:element minOccurs="0" maxOccurs="1" default="Unknown" name="Manga" type="Manga" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Characters" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Teams" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="Locations" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="ScanInformation" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="StoryArc" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="" name="SeriesGroup" type="xs:string" />
<xs:element minOccurs="0" maxOccurs="1" default="Unknown" name="AgeRating" type="AgeRating" />
<xs:element minOccurs="0" maxOccurs="1" name="Pages" type="ArrayOfComicPageInfo" />
</xs:sequence>
</xs:complexType>
<xs:simpleType name="YesNo">
<xs:restriction base="xs:string">
<xs:enumeration value="Unknown" />
<xs:enumeration value="No" />
<xs:enumeration value="Yes" />
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="Manga">
<xs:restriction base="xs:string">
<xs:enumeration value="Unknown" />
<xs:enumeration value="No" />
<xs:enumeration value="Yes" />
<xs:enumeration value="YesAndRightToLeft" />
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="AgeRating">
<xs:restriction base="xs:string">
<xs:enumeration value="Unknown" />
<xs:enumeration value="Adults Only 18+" />
<xs:enumeration value="Early Childhood" />
<xs:enumeration value="Everyone" />
<xs:enumeration value="Everyone 10+" />
<xs:enumeration value="G" />
<xs:enumeration value="Kids to Adults" />
<xs:enumeration value="M" />
<xs:enumeration value="MA 15+" />
<xs:enumeration value="Mature 17+" />
<xs:enumeration value="PG" />
<xs:enumeration value="R18+" />
<xs:enumeration value="Rating Pending" />
<xs:enumeration value="Teen" />
<xs:enumeration value="X18+" />
</xs:restriction>
</xs:simpleType>
<xs:complexType name="ArrayOfComicPageInfo">
<xs:sequence>
<xs:element minOccurs="0" maxOccurs="unbounded" name="Page" nillable="true" type="ComicPageInfo" />
</xs:sequence>
</xs:complexType>
<xs:complexType name="ComicPageInfo">
<xs:attribute name="Image" type="xs:int" use="required" />
<xs:attribute default="Story" name="Type" type="ComicPageType" />
<xs:attribute default="false" name="DoublePage" type="xs:boolean" />
<xs:attribute default="0" name="ImageSize" type="xs:long" />
<xs:attribute default="" name="Key" type="xs:string" />
<xs:attribute default="-1" name="ImageWidth" type="xs:int" />
<xs:attribute default="-1" name="ImageHeight" type="xs:int" />
</xs:complexType>
<xs:simpleType name="ComicPageType">
<xs:list>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:enumeration value="FrontCover" />
<xs:enumeration value="InnerCover" />
<xs:enumeration value="Roundup" />
<xs:enumeration value="Story" />
<xs:enumeration value="Advertisment" />
<xs:enumeration value="Editorial" />
<xs:enumeration value="Letters" />
<xs:enumeration value="Preview" />
<xs:enumeration value="BackCover" />
<xs:enumeration value="Other" />
<xs:enumeration value="Deleted" />
</xs:restriction>
</xs:simpleType>
</xs:list>
</xs:simpleType>
</xs:schema>

View file

@ -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<Book>) {
bookRepository
.findAllById(books.map { it.id })
.forEach { metadataLifecycle.refreshMetadata(it) }
}
}

View file

@ -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)
}
}
}
}

View file

@ -27,4 +27,5 @@ class Author {
field = value.trim().toLowerCase()
}
override fun toString(): String = "Author($name, $role)"
}

View file

@ -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<Author>?
)

View file

@ -26,21 +26,23 @@ private val natSortComparator: Comparator<String> = 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<BookPage> = emptyList(),
pages: Iterable<BookPage> = emptyList(),
@Column(name = "comment")
var comment: String? = null
files: Iterable<String> = 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<String> = mutableListOf()
var files: List<String>
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 {

View file

@ -0,0 +1,7 @@
package org.gotson.komga.domain.model
class SeriesMetadataPatch(
val title: String?,
val titleSort: String?,
val status: SeriesMetadata.Status?
)

View file

@ -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)
}
}

View file

@ -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)
}
}
}

View file

@ -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" }
}
}
}
}

View file

@ -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
}
}

View file

@ -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?
}

View file

@ -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?
}

View file

@ -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<Author>()
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) }
}

View file

@ -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]
}
}

View file

@ -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
}

View file

@ -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]
}
}

View file

@ -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]
}
}

View file

@ -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(

View file

@ -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(

View file

@ -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(

View file

@ -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);

View file

@ -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<XmlMapper>()
private val mockAnalyzer = mockk<BookAnalyzer>().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<ByteArray>(), 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<ByteArray>(), 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<ByteArray>(), 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<ByteArray>(), 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<ByteArray>(), 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<ByteArray>(), ComicInfo::class.java) } returns comicInfo
val patch = comicInfoProvider.getSeriesMetadataFromBook(book)
with(patch!!) {
assertThat(title).isEqualTo("series")
assertThat(titleSort).isEqualTo("series")
assertThat(status).isNull()
}
}
}
}

View file

@ -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()
}
}
}

View file

@ -0,0 +1,25 @@
<?xml version="1.0"?>
<ComicInfo xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Title>v01 - Preludes &amp; Nocturnes - 30th Anniversary Edition</Title>
<Series>Sandman</Series>
<Web>https://www.comixology.com/Sandman/digital-comic/727888</Web>
<Summary>Neil Gaiman's seminal series, THE SANDMAN, celebrates its 30th anniversary with an all-new edition of THE SANDMAN VOL. 1: PRELUDES &amp; 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 &amp; 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.</Summary>
<Notes>Scraped metadata from Comixology [CMXDB727888], [RELDATE:2018-10-30]</Notes>
<Publisher>DC</Publisher>
<Imprint>Vertigo</Imprint>
<Genre>Fantasy, Supernatural/Occult, Horror, Mature, Superhero, Mythology, Drama</Genre>
<PageCount>237</PageCount>
<LanguageISO>en</LanguageISO>
<AgeRating>Mature 17+</AgeRating>
<BlackAndWhite>No</BlackAndWhite>
<Manga>No</Manga>
<ScanInformation></ScanInformation>
</ComicInfo>

View file

@ -0,0 +1,6 @@
<?xml version="1.0"?>
<ComicInfo xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<AgeRating>Non existent</AgeRating>
<BlackAndWhite>Non existent</BlackAndWhite>
<Manga>Non existent</Manga>
</ComicInfo>