Update Local Source to latest Tachiyomi (#637)

* Update Local Source to latest Tachiyomi

* More formatting

* Enable zip64
This commit is contained in:
Mitchell Syer
2023-08-06 23:20:55 -04:00
committed by GitHub
parent 00bc055d69
commit b56b4fa813
22 changed files with 890 additions and 547 deletions

View File

@@ -12,6 +12,7 @@ settings = "1.0.0-RC"
twelvemonkeys = "3.9.4" twelvemonkeys = "3.9.4"
playwright = "1.28.0" playwright = "1.28.0"
graphqlkotlin = "6.5.3" graphqlkotlin = "6.5.3"
xmlserialization = "0.86.1"
[libraries] [libraries]
# Kotlin # Kotlin
@@ -27,6 +28,8 @@ coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", ve
# Serialization # Serialization
serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "serialization" } serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "serialization" }
serialization-xml-core = { module = "io.github.pdvrieze.xmlutil:core-jvm", version.ref = "xmlserialization" }
serialization-xml = { module = "io.github.pdvrieze.xmlutil:serialization-jvm", version.ref = "xmlserialization" }
# Logging # Logging
slf4japi = "org.slf4j:slf4j-api:2.0.7" slf4japi = "org.slf4j:slf4j-api:2.0.7"
@@ -100,7 +103,7 @@ xmlpull = "xmlpull:xmlpull:1.1.3.4a"
appdirs = "net.harawata:appdirs:1.2.1" appdirs = "net.harawata:appdirs:1.2.1"
zip4j = "net.lingala.zip4j:zip4j:2.11.5" zip4j = "net.lingala.zip4j:zip4j:2.11.5"
commonscompress = "org.apache.commons:commons-compress:1.23.0" commonscompress = "org.apache.commons:commons-compress:1.23.0"
junrar = "com.github.junrar:junrar:7.5.4" junrar = "com.github.junrar:junrar:7.5.5"
# CloudflareInterceptor # CloudflareInterceptor
playwright = { module = "com.microsoft.playwright:playwright", version.ref = "playwright" } playwright = { module = "com.microsoft.playwright:playwright", version.ref = "playwright" }

View File

@@ -45,6 +45,10 @@ dependencies {
implementation(libs.rxjava) implementation(libs.rxjava)
implementation(libs.jsoup) implementation(libs.jsoup)
// ComicInfo
implementation(libs.serialization.xml.core)
implementation(libs.serialization.xml)
// Sort // Sort
implementation(libs.sort) implementation(libs.sort)
@@ -114,6 +118,7 @@ buildConfig {
tasks { tasks {
shadowJar { shadowJar {
isZip64 = true
manifest { manifest {
attributes( attributes(
"Main-Class" to MainClass, "Main-Class" to MainClass,

View File

@@ -4,6 +4,7 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import rx.Observable import rx.Observable
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
/** /**
* A basic interface for creating a source. It could be an online source, a local source, etc... * A basic interface for creating a source. It could be an online source, a local source, etc...
@@ -25,21 +26,59 @@ interface Source {
* *
* @param manga the manga to update. * @param manga the manga to update.
*/ */
fun fetchMangaDetails(manga: SManga): Observable<SManga> @Deprecated(
"Use the 1.x API instead",
ReplaceWith("getMangaDetails")
)
fun fetchMangaDetails(manga: SManga): Observable<SManga> = throw IllegalStateException("Not used")
/** /**
* Returns an observable with all the available chapters for a manga. * Returns an observable with all the available chapters for a manga.
* *
* @param manga the manga to update. * @param manga the manga to update.
*/ */
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> @Deprecated(
"Use the 1.x API instead",
ReplaceWith("getChapterList")
)
fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = throw IllegalStateException("Not used")
/** /**
* Returns an observable with the list of pages a chapter has. * Returns an observable with the list of pages a chapter has. Pages should be returned
* in the expected order; the index is ignored.
* *
* @param chapter the chapter. * @param chapter the chapter.
*/ */
fun fetchPageList(chapter: SChapter): Observable<List<Page>> @Deprecated(
"Use the 1.x API instead",
ReplaceWith("getPageList")
)
fun fetchPageList(chapter: SChapter): Observable<List<Page>> = Observable.empty()
/**
* [1.x API] Get the updated details for a manga.
*/
@Suppress("DEPRECATION")
suspend fun getMangaDetails(manga: SManga): SManga {
return fetchMangaDetails(manga).awaitSingle()
}
/**
* [1.x API] Get all the available chapters for a manga.
*/
@Suppress("DEPRECATION")
suspend fun getChapterList(manga: SManga): List<SChapter> {
return fetchChapterList(manga).awaitSingle()
}
/**
* [1.x API] Get the list of pages a chapter has. Pages should be returned
* in the expected order; the index is ignored.
*/
@Suppress("DEPRECATION")
suspend fun getPageList(chapter: SChapter): List<Page> {
return fetchPageList(chapter).awaitSingle()
}
} }
// fun Source.icon(): Drawable? = Injekt.get<ExtensionManager>().getAppIconForSource(this) // fun Source.icon(): Drawable? = Injekt.get<ExtensionManager>().getAppIconForSource(this)

View File

@@ -1,15 +1,21 @@
package eu.kanade.tachiyomi.source.local package eu.kanade.tachiyomi.source.local
import com.github.junrar.Archive
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Directory import eu.kanade.tachiyomi.source.UnmeteredSource
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Epub import eu.kanade.tachiyomi.source.local.filter.OrderBy
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Rar import eu.kanade.tachiyomi.source.local.image.LocalCoverManager
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Zip import eu.kanade.tachiyomi.source.local.io.Archive
import eu.kanade.tachiyomi.source.local.io.Format
import eu.kanade.tachiyomi.source.local.io.LocalSourceFileSystem
import eu.kanade.tachiyomi.source.local.loader.EpubPageLoader import eu.kanade.tachiyomi.source.local.loader.EpubPageLoader
import eu.kanade.tachiyomi.source.local.loader.RarPageLoader import eu.kanade.tachiyomi.source.local.loader.RarPageLoader
import eu.kanade.tachiyomi.source.local.loader.ZipPageLoader import eu.kanade.tachiyomi.source.local.loader.ZipPageLoader
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.local.metadata.COMIC_INFO_FILE
import eu.kanade.tachiyomi.source.local.metadata.ComicInfo
import eu.kanade.tachiyomi.source.local.metadata.MangaDetails
import eu.kanade.tachiyomi.source.local.metadata.copyFromComicInfo
import eu.kanade.tachiyomi.source.local.metadata.fillChapterMetadata
import eu.kanade.tachiyomi.source.local.metadata.fillMangaMetadata
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
@@ -18,14 +24,14 @@ import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.EpubFile import eu.kanade.tachiyomi.util.storage.EpubFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import mu.KotlinLogging import mu.KotlinLogging
import nl.adaptivity.xmlutil.core.KtXmlReader
import nl.adaptivity.xmlutil.serialization.XML
import org.apache.commons.compress.archivers.zip.ZipFile import org.apache.commons.compress.archivers.zip.ZipFile
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.insertAndGetId import org.jetbrains.exposed.sql.insertAndGetId
@@ -44,10 +50,348 @@ import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.InputStream import java.io.InputStream
import java.util.Locale import java.nio.charset.StandardCharsets
import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.days
import com.github.junrar.Archive as JunrarArchive
class LocalSource(
private val fileSystem: LocalSourceFileSystem,
private val coverManager: LocalCoverManager
) : CatalogueSource, UnmeteredSource {
private val json: Json by injectLazy()
private val xml: XML by injectLazy()
private val POPULAR_FILTERS = FilterList(OrderBy.Popular())
private val LATEST_FILTERS = FilterList(OrderBy.Latest())
override val name: String = NAME
override val id: Long = ID
override val lang: String = LANG
override fun toString() = name
override val supportsLatest: Boolean = true
// Browse related
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS)
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
val baseDirsFiles = fileSystem.getFilesInBaseDirectories()
val lastModifiedLimit by lazy { if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L }
var mangaDirs = baseDirsFiles
// Filter out files that are hidden and is not a folder
.filter { it.isDirectory && !it.name.startsWith('.') }
.distinctBy { it.name }
.filter { // Filter by query or last modified
if (lastModifiedLimit == 0L) {
it.name.contains(query, ignoreCase = true)
} else {
it.lastModified() >= lastModifiedLimit
}
}
filters.forEach { filter ->
when (filter) {
is OrderBy.Popular -> {
mangaDirs = if (filter.state!!.ascending) {
mangaDirs.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name })
} else {
mangaDirs.sortedWith(compareByDescending(String.CASE_INSENSITIVE_ORDER) { it.name })
}
}
is OrderBy.Latest -> {
mangaDirs = if (filter.state!!.ascending) {
mangaDirs.sortedBy(File::lastModified)
} else {
mangaDirs.sortedByDescending(File::lastModified)
}
}
else -> {
/* Do nothing */
}
}
}
// Transform mangaDirs to list of SManga
val mangas = mangaDirs.map { mangaDir ->
SManga.create().apply {
title = mangaDir.name
url = mangaDir.name
// Try to find the cover
coverManager.find(mangaDir.name)
?.takeIf(File::exists)
?.let { thumbnail_url = it.absolutePath }
}
}
// Fetch chapters of all the manga
mangas.forEach { manga ->
runBlocking {
val chapters = getChapterList(manga)
if (chapters.isNotEmpty()) {
val chapter = chapters.last()
val format = getFormat(chapter)
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillMangaMetadata(manga)
}
}
// Copy the cover from the first chapter found if not available
if (manga.thumbnail_url == null) {
updateCover(chapter, manga)
}
}
}
}
return Observable.just(MangasPage(mangas.toList(), false))
}
// Manga details related
override suspend fun getMangaDetails(manga: SManga): SManga = withContext(Dispatchers.IO) {
coverManager.find(manga.url)?.let {
manga.thumbnail_url = it.absolutePath
}
// Augment manga details based on metadata files
try {
val mangaDirFiles = fileSystem.getFilesInMangaDirectory(manga.url).toList()
val comicInfoFile = mangaDirFiles
.firstOrNull { it.name == COMIC_INFO_FILE }
val noXmlFile = mangaDirFiles
.firstOrNull { it.name == ".noxml" }
val legacyJsonDetailsFile = mangaDirFiles
.firstOrNull { it.extension == "json" }
when {
// Top level ComicInfo.xml
comicInfoFile != null -> {
noXmlFile?.delete()
setMangaDetailsFromComicInfoFile(comicInfoFile.inputStream(), manga)
}
// TODO: automatically convert these to ComicInfo.xml
legacyJsonDetailsFile != null -> {
json.decodeFromStream<MangaDetails>(legacyJsonDetailsFile.inputStream()).run {
title?.let { manga.title = it }
author?.let { manga.author = it }
artist?.let { manga.artist = it }
description?.let { manga.description = it }
genre?.let { manga.genre = it.joinToString() }
status?.let { manga.status = it }
}
}
// Copy ComicInfo.xml from chapter archive to top level if found
noXmlFile == null -> {
val chapterArchives = mangaDirFiles
.filter(Archive::isSupported)
.toList()
val mangaDir = fileSystem.getMangaDirectory(manga.url)
val folderPath = mangaDir?.absolutePath
val copiedFile = copyComicInfoFileFromArchive(chapterArchives, folderPath)
if (copiedFile != null) {
setMangaDetailsFromComicInfoFile(copiedFile.inputStream(), manga)
} else {
// Avoid re-scanning
File("$folderPath/.noxml").createNewFile()
}
}
}
} catch (e: Throwable) {
logger.error(e) { "Error setting manga details from local metadata for ${manga.title}" }
}
return@withContext manga
}
private fun copyComicInfoFileFromArchive(chapterArchives: List<File>, folderPath: String?): File? {
for (chapter in chapterArchives) {
when (Format.valueOf(chapter)) {
is Format.Zip -> {
ZipFile(chapter).use { zip: ZipFile ->
zip.getEntry(COMIC_INFO_FILE)?.let { comicInfoFile ->
zip.getInputStream(comicInfoFile).buffered().use { stream ->
return copyComicInfoFile(stream, folderPath)
}
}
}
}
is Format.Rar -> {
JunrarArchive(chapter).use { rar ->
rar.fileHeaders.firstOrNull { it.fileName == COMIC_INFO_FILE }?.let { comicInfoFile ->
rar.getInputStream(comicInfoFile).buffered().use { stream ->
return copyComicInfoFile(stream, folderPath)
}
}
}
}
else -> {}
}
}
return null
}
private fun copyComicInfoFile(comicInfoFileStream: InputStream, folderPath: String?): File {
return File("$folderPath/$COMIC_INFO_FILE").apply {
outputStream().use { outputStream ->
comicInfoFileStream.use { it.copyTo(outputStream) }
}
}
}
private fun setMangaDetailsFromComicInfoFile(stream: InputStream, manga: SManga) {
val comicInfo = KtXmlReader(stream, StandardCharsets.UTF_8.name()).use {
xml.decodeFromReader<ComicInfo>(it)
}
manga.copyFromComicInfo(comicInfo)
}
// Chapters
override suspend fun getChapterList(manga: SManga): List<SChapter> {
return fileSystem.getFilesInMangaDirectory(manga.url)
// Only keep supported formats
.filter { it.isDirectory || Archive.isSupported(it) }
.map { chapterFile ->
SChapter.create().apply {
url = "${manga.url}/${chapterFile.name}"
name = if (chapterFile.isDirectory) {
chapterFile.name
} else {
chapterFile.nameWithoutExtension
}
date_upload = chapterFile.lastModified()
chapter_number = ChapterRecognition
.parseChapterNumber(manga.title, this.name, this.chapter_number.toDouble())
.toFloat()
val format = Format.valueOf(chapterFile)
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillChapterMetadata(this)
}
}
}
}
.sortedWith { c1, c2 ->
val c = c2.chapter_number.compareTo(c1.chapter_number)
if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
}
.toList()
}
// Filters
override fun getFilterList() = FilterList(OrderBy.Popular())
// TODO Fix Memory Leak
override suspend fun getPageList(chapter: SChapter): List<Page> {
return when (val format = getFormat(chapter)) {
is Format.Directory -> {
format.file.listFiles().orEmpty()
.sortedBy { it.name }
.filter { !it.isDirectory && ImageUtil.isImage(it.name, it::inputStream) }
.mapIndexed { index, page ->
Page(
index,
imageUrl = applicationDirs.localMangaRoot + "/" + chapter.url + "/" + page.name
)
}
}
is Format.Zip -> {
val loader = ZipPageLoader(format.file)
val pages = loader.getPages()
pageCache[chapter.url] = pages.map { it.stream!! }
pages
}
is Format.Rar -> {
val loader = RarPageLoader(format.file)
val pages = loader.getPages()
pageCache[chapter.url] = pages.map { it.stream!! }
pages
}
is Format.Epub -> {
val loader = EpubPageLoader(format.file)
val pages = loader.getPages()
pageCache[chapter.url] = pages.map { it.stream!! }
pages
}
}
}
fun getFormat(chapter: SChapter): Format {
try {
return fileSystem.getBaseDirectories()
.map { dir -> File(dir, chapter.url) }
.find { it.exists() }
?.let(Format.Companion::valueOf)
?: throw Exception("Chapter not found")
} catch (e: Format.UnknownFormatException) {
throw Exception("Invalid chapter format")
} catch (e: Exception) {
throw e
}
}
private fun updateCover(chapter: SChapter, manga: SManga): File? {
return try {
when (val format = getFormat(chapter)) {
is Format.Directory -> {
val entry = format.file.listFiles()
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
entry?.let { coverManager.update(manga, it.inputStream()) }
}
is Format.Zip -> {
ZipFile(format.file).use { zip ->
val entry = zip.entries.toList()
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
entry?.let { coverManager.update(manga, zip.getInputStream(it)) }
}
}
is Format.Rar -> {
JunrarArchive(format.file).use { archive ->
val entry = archive.fileHeaders
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
entry?.let { coverManager.update(manga, archive.getInputStream(it)) }
}
}
is Format.Epub -> {
EpubFile(format.file).use { epub ->
val entry = epub.getImagesFromPages()
.firstOrNull()
?.let { epub.getEntry(it) }
entry?.let { coverManager.update(manga, epub.getInputStream(it)) }
}
}
}
} catch (e: Throwable) {
logger.error(e) { "Error updating cover for ${manga.title}" }
null
}
}
class LocalSource : CatalogueSource {
companion object { companion object {
const val ID = 0L const val ID = 0L
const val LANG = "localsourcelang" const val LANG = "localsourcelang"
@@ -55,11 +399,7 @@ class LocalSource : CatalogueSource {
const val EXTENSION_NAME = "Local Source fake extension" const val EXTENSION_NAME = "Local Source fake extension"
const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/" private val LATEST_THRESHOLD = 7.days.inWholeMilliseconds
private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub")
private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@@ -67,29 +407,6 @@ class LocalSource : CatalogueSource {
val pageCache: MutableMap<String, List<() -> InputStream>> = mutableMapOf() val pageCache: MutableMap<String, List<() -> InputStream>> = mutableMapOf()
fun updateCover(manga: SManga, input: InputStream): File? {
val cover = getCoverFile(File("${applicationDirs.localMangaRoot}/${manga.url}"))
?: File("${applicationDirs.localMangaRoot}/${manga.url}/cover.jpg")
cover.parentFile?.mkdirs()
input.use {
cover.outputStream().use {
input.copyTo(it)
}
}
return cover
}
/**
* Returns valid cover file inside [parent] directory.
*/
private fun getCoverFile(parent: File): File? {
return parent.listFiles()?.find { it.nameWithoutExtension == "cover" }?.takeIf {
it.isFile && ImageUtil.isImage(it.name) { it.inputStream() }
}
}
fun register() { fun register() {
transaction { transaction {
val sourceRecord = SourceTable.select { SourceTable.id eq ID }.firstOrNull() val sourceRecord = SourceTable.select { SourceTable.id eq ID }.firstOrNull()
@@ -117,294 +434,8 @@ class LocalSource : CatalogueSource {
} }
} }
registerCatalogueSource(ID to LocalSource()) val fs = LocalSourceFileSystem(applicationDirs)
registerCatalogueSource(ID to LocalSource(fs, LocalCoverManager(fs)))
} }
} }
override val id = ID
override val name = NAME
override val lang = LANG
override val supportsLatest = true
private val json: Json by injectLazy()
override fun toString() = name
override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS)
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
var mangaDirs = File(applicationDirs.localMangaRoot).listFiles().orEmpty().toList()
.filter { it.isDirectory }
.filterNot { it.name.startsWith('.') }
.filter { if (time == 0L) it.name.contains(query, ignoreCase = true) else it.lastModified() >= time }
.distinctBy { it.name }
val state = ((if (filters.isEmpty()) POPULAR_FILTERS else filters)[0] as OrderBy).state
when (state?.index) {
0 -> {
mangaDirs = if (state.ascending) {
mangaDirs.sortedBy { it.name.lowercase(Locale.ENGLISH) }
} else {
mangaDirs.sortedByDescending { it.name.lowercase(Locale.ENGLISH) }
}
}
1 -> {
mangaDirs = if (state.ascending) {
mangaDirs.sortedBy(File::lastModified)
} else {
mangaDirs.sortedByDescending(File::lastModified)
}
}
}
val mangas = mangaDirs.map { mangaDir ->
SManga.create().apply {
title = mangaDir.name
url = mangaDir.name
// Try to find the cover
val cover = getCoverFile(File("${applicationDirs.localMangaRoot}/$url"))
if (cover != null && cover.exists()) {
thumbnail_url = cover.absolutePath
}
val chapters = fetchChapterList(this).toBlocking().first()
if (chapters.isNotEmpty()) {
val chapter = chapters.last()
val format = getFormat(chapter)
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillMangaMetadata(this)
}
}
// Copy the cover from the first chapter found.
if (thumbnail_url == null) {
try {
thumbnail_url = updateCover(chapter, this)?.absolutePath
} catch (e: Exception) {
logger.error { e }
}
}
}
}
}
return Observable.just(MangasPage(mangas.toList(), false))
}
override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
File(applicationDirs.localMangaRoot, manga.url).listFiles().orEmpty().toList()
.firstOrNull { it.extension == "json" }
?.apply {
val obj = json.decodeFromStream<JsonObject>(inputStream())
manga.title = obj["title"]?.jsonPrimitive?.contentOrNull ?: manga.title
manga.author = obj["author"]?.jsonPrimitive?.contentOrNull ?: manga.author
manga.artist = obj["artist"]?.jsonPrimitive?.contentOrNull ?: manga.artist
manga.description = obj["description"]?.jsonPrimitive?.contentOrNull ?: manga.description
manga.genre = obj["genre"]?.jsonArray?.joinToString(", ") { it.jsonPrimitive.content }
?: manga.genre
manga.status = obj["status"]?.jsonPrimitive?.intOrNull ?: manga.status
}
// update the cover
val cover = getCoverFile(File("${applicationDirs.localMangaRoot}/${manga.url}"))
if (cover != null && cover.exists()) {
manga.thumbnail_url = cover.absolutePath
}
return Observable.just(manga)
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
val chapters = File(applicationDirs.localMangaRoot, manga.url).listFiles().orEmpty().toList()
.filter { it.isDirectory || isSupportedFile(it.extension) }
.map { chapterFile ->
SChapter.create().apply {
url = "${manga.url}/${chapterFile.name}"
name = if (chapterFile.isDirectory) {
chapterFile.name
} else {
chapterFile.nameWithoutExtension
}
date_upload = chapterFile.lastModified()
val format = getFormat(this)
if (format is Format.Epub) {
EpubFile(format.file).use { epub ->
epub.fillChapterMetadata(this)
}
}
val chapNameCut = stripMangaTitle(name, manga.title)
if (chapNameCut.isNotEmpty()) name = chapNameCut
ChapterRecognition.parseChapterNumber(this, manga)
}
}
.sortedWith { c1, c2 ->
val c = c2.chapter_number.compareTo(c1.chapter_number)
if (c == 0) c2.name.compareToCaseInsensitiveNaturalOrder(c1.name) else c
}
.toList()
return Observable.just(chapters)
}
/**
* Strips the manga title from a chapter name, matching only based on alphanumeric and whitespace
* characters.
*/
private fun stripMangaTitle(chapterName: String, mangaTitle: String): String {
var chapterNameIndex = 0
var mangaTitleIndex = 0
while (chapterNameIndex < chapterName.length && mangaTitleIndex < mangaTitle.length) {
val chapterChar = chapterName[chapterNameIndex]
val mangaChar = mangaTitle[mangaTitleIndex]
if (!chapterChar.equals(mangaChar, true)) {
val invalidChapterChar = !chapterChar.isLetterOrDigit() && !chapterChar.isWhitespace()
val invalidMangaChar = !mangaChar.isLetterOrDigit() && !mangaChar.isWhitespace()
if (!invalidChapterChar && !invalidMangaChar) {
return chapterName
}
if (invalidChapterChar) {
chapterNameIndex++
}
if (invalidMangaChar) {
mangaTitleIndex++
}
} else {
chapterNameIndex++
mangaTitleIndex++
}
}
return chapterName.substring(chapterNameIndex).trimStart(' ', '-', '_', ',', ':')
}
private fun isSupportedFile(extension: String): Boolean {
return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES
}
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
val chapterFile = File(applicationDirs.localMangaRoot + "/" + chapter.url)
return when (getFormat(chapterFile)) {
is Directory -> {
Observable.just(
chapterFile.listFiles().orEmpty()
.sortedBy { it.name }
.filter { !it.isDirectory && ImageUtil.isImage(it.name, it::inputStream) }
.mapIndexed { index, page ->
Page(
index,
imageUrl = applicationDirs.localMangaRoot + "/" + chapter.url + "/" + page.name
)
}
)
}
is Zip -> {
val pages = ZipPageLoader(chapterFile).getPages()
pageCache[chapter.url] = pages.map { it.stream!! }
Observable.just(pages)
}
is Rar -> {
val pages = RarPageLoader(chapterFile).getPages()
pageCache[chapter.url] = pages.map { it.stream!! }
Observable.just(pages)
}
is Epub -> {
val pages = EpubPageLoader(chapterFile).getPages()
pageCache[chapter.url] = pages.map { it.stream!! }
Observable.just(pages)
}
}
}
fun getFormat(chapter: SChapter): Format {
val chapFile = File(applicationDirs.localMangaRoot, chapter.url)
if (chapFile.exists()) {
return getFormat(chapFile)
}
throw Exception("Chapter not found")
}
private fun getFormat(file: File): Format = with(file) {
when {
isDirectory -> Format.Directory(this)
extension.equals("zip", true) || extension.equals("cbz", true) -> Format.Zip(this)
extension.equals("rar", true) || extension.equals("cbr", true) -> Format.Rar(this)
extension.equals("epub", true) -> Format.Epub(this)
else -> throw Exception("Invalid chapter format")
}
}
private fun updateCover(chapter: SChapter, manga: SManga): File? {
return when (val format = getFormat(chapter)) {
is Format.Directory -> {
val entry = format.file.listFiles()
?.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
?.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
entry?.let { updateCover(manga, it.inputStream()) }
}
is Format.Zip -> {
ZipFile(format.file).use { zip ->
val entry = zip.entries.toList()
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
entry?.let { updateCover(manga, zip.getInputStream(it)) }
}
}
is Format.Rar -> {
Archive(format.file).use { archive ->
val entry = archive.fileHeaders
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
entry?.let { updateCover(manga, archive.getInputStream(it)) }
}
}
is Format.Epub -> {
EpubFile(format.file).use { epub ->
val entry = epub.getImagesFromPages()
.firstOrNull()
?.let { epub.getEntry(it) }
entry?.let { updateCover(manga, epub.getInputStream(it)) }
}
}
}
}
override fun getFilterList() = POPULAR_FILTERS
private val POPULAR_FILTERS = FilterList(OrderBy())
private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) })
private class OrderBy : Filter.Sort(
"Order by",
arrayOf("Title", "Date"),
Selection(0, true)
)
sealed class Format {
data class Directory(val file: File) : Format()
data class Zip(val file: File) : Format()
data class Rar(val file: File) : Format()
data class Epub(val file: File) : Format()
}
} }

View File

@@ -0,0 +1,12 @@
package eu.kanade.tachiyomi.source.local.filter
import eu.kanade.tachiyomi.source.model.Filter
sealed class OrderBy(selection: Selection) : Filter.Sort(
"Order by",
arrayOf("Title", "Date"),
selection
) {
class Popular() : OrderBy(Selection(0, true))
class Latest() : OrderBy(Selection(1, false))
}

View File

@@ -0,0 +1,52 @@
package eu.kanade.tachiyomi.source.local.image
import eu.kanade.tachiyomi.source.local.io.LocalSourceFileSystem
import eu.kanade.tachiyomi.source.model.SManga
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import java.io.File
import java.io.InputStream
private const val DEFAULT_COVER_NAME = "cover.jpg"
class LocalCoverManager(
private val fileSystem: LocalSourceFileSystem
) {
fun find(mangaUrl: String): File? {
return fileSystem.getFilesInMangaDirectory(mangaUrl)
// Get all file whose names start with 'cover'
.filter { it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true) }
// Get the first actual image
.firstOrNull {
ImageUtil.isImage(it.name) { it.inputStream() }
}
}
fun update(
manga: SManga,
inputStream: InputStream
): File? {
val directory = fileSystem.getMangaDirectory(manga.url)
if (directory == null) {
inputStream.close()
return null
}
var targetFile = find(manga.url)
if (targetFile == null) {
targetFile = File(directory.absolutePath, DEFAULT_COVER_NAME)
targetFile.createNewFile()
}
// It might not exist at this point
targetFile.parentFile?.mkdirs()
inputStream.use { input ->
targetFile.outputStream().use { output ->
input.copyTo(output)
}
}
manga.thumbnail_url = targetFile.absolutePath
return targetFile
}
}

View File

@@ -0,0 +1,12 @@
package eu.kanade.tachiyomi.source.local.io
import java.io.File
object Archive {
private val SUPPORTED_ARCHIVE_TYPES = listOf("zip", "cbz", "rar", "cbr", "epub")
fun isSupported(file: File): Boolean = with(file) {
return extension.lowercase() in SUPPORTED_ARCHIVE_TYPES
}
}

View File

@@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.source.local.io
import java.io.File
sealed interface Format {
data class Directory(val file: File) : Format
data class Zip(val file: File) : Format
data class Rar(val file: File) : Format
data class Epub(val file: File) : Format
class UnknownFormatException : Exception()
companion object {
fun valueOf(file: File) = with(file) {
when {
isDirectory -> Directory(this)
extension.equals("zip", true) || extension.equals("cbz", true) -> Zip(this)
extension.equals("rar", true) || extension.equals("cbr", true) -> Rar(this)
extension.equals("epub", true) -> Epub(this)
else -> throw UnknownFormatException()
}
}
}
}

View File

@@ -0,0 +1,33 @@
package eu.kanade.tachiyomi.source.local.io
import suwayomi.tachidesk.server.ApplicationDirs
import java.io.File
class LocalSourceFileSystem(
private val applicationDirs: ApplicationDirs
) {
fun getBaseDirectories(): Sequence<File> {
return sequenceOf(File(applicationDirs.localMangaRoot))
}
fun getFilesInBaseDirectories(): Sequence<File> {
return getBaseDirectories()
// Get all the files inside all baseDir
.flatMap { it.listFiles().orEmpty().toList() }
}
fun getMangaDirectory(name: String): File? {
return getFilesInBaseDirectories()
// Get the first mangaDir or null
.firstOrNull { it.isDirectory && it.name == name }
}
fun getFilesInMangaDirectory(name: String): Sequence<File> {
return getFilesInBaseDirectories()
// Filter out ones that are not related to the manga and is not a directory
.filter { it.isDirectory && it.name == name }
// Get all the files inside the filtered folders
.flatMap { it.listFiles().orEmpty().toList() }
}
}

View File

@@ -8,16 +8,9 @@ import java.io.File
*/ */
class EpubPageLoader(file: File) : PageLoader { class EpubPageLoader(file: File) : PageLoader {
/**
* The epub file.
*/
private val epub = EpubFile(file) private val epub = EpubFile(file)
/** override suspend fun getPages(): List<ReaderPage> {
* Returns an observable containing the pages found on this zip archive ordered with a natural
* comparator.
*/
override fun getPages(): List<ReaderPage> {
return epub.getImagesFromPages() return epub.getImagesFromPages()
.mapIndexed { i, path -> .mapIndexed { i, path ->
val streamFn = { epub.getInputStream(epub.getEntry(path)!!) } val streamFn = { epub.getInputStream(epub.getEntry(path)!!) }
@@ -26,4 +19,8 @@ class EpubPageLoader(file: File) : PageLoader {
} }
} }
} }
override fun recycle() {
epub.close()
}
} }

View File

@@ -6,5 +6,7 @@ interface PageLoader {
* Returns an observable containing the list of pages of a chapter. Only the first emission * Returns an observable containing the list of pages of a chapter. Only the first emission
* will be used. * will be used.
*/ */
fun getPages(): List<ReaderPage> suspend fun getPages(): List<ReaderPage>
fun recycle()
} }

View File

@@ -4,59 +4,48 @@ import com.github.junrar.Archive
import com.github.junrar.rarfile.FileHeader import com.github.junrar.rarfile.FileHeader
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
/** /**
* Loader used to load a chapter from a .rar or .cbr file. * Loader used to load a chapter from a .rar or .cbr file.
*/ */
class RarPageLoader(file: File) : PageLoader { class RarPageLoader(file: File) : PageLoader {
/** private val rar = Archive(file)
* The rar archive to load pages from.
*/
private val archive = Archive(file)
/** override suspend fun getPages(): List<ReaderPage> {
* The fully uncompressed files, to be used in case archive is solid. return rar.fileHeaders.asSequence()
*/ .filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { rar.getInputStream(it) } }
private var archiveMap = mutableMapOf<FileHeader, InputStream>()
/**
* Returns an observable containing the pages found on this rar archive ordered with a natural
* comparator.
*/
override fun getPages(): List<ReaderPage> {
if (archive.mainHeader.isSolid) {
// Solid means that we need to read all the file sequentially
for (header in archive.fileHeaders) {
val baos = ByteArrayOutputStream()
archive.extractFile(header, baos)
archiveMap[header] = ByteArrayInputStream(baos.toByteArray())
}
// After reading the full archive, proceed to filter and transform
return archive.fileHeaders
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { archiveMap.getValue(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.mapIndexed { i, header ->
val streamFn = { archiveMap.getValue(header) }
ReaderPage(i).apply {
stream = streamFn
}
}
}
return archive.fileHeaders
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) } .sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.mapIndexed { i, header -> .mapIndexed { i, header ->
val streamFn = { archive.getInputStream(header) }
ReaderPage(i).apply { ReaderPage(i).apply {
stream = streamFn stream = { getStream(rar, header) }
} }
} }
.toList()
}
override fun recycle() {
rar.close()
}
/**
* Returns an input stream for the given [header].
*/
private fun getStream(rar: Archive, header: FileHeader): InputStream {
val pipeIn = PipedInputStream()
val pipeOut = PipedOutputStream(pipeIn)
synchronized(this) {
try {
pipeOut.use {
rar.extractFile(header, it)
}
} catch (_: Exception) {
}
}
return pipeIn
} }
} }

View File

@@ -5,25 +5,26 @@ import org.apache.commons.compress.archivers.zip.ZipFile
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import java.io.File import java.io.File
/**
* Loader used to load a chapter from a .zip or .cbz file.
*/
class ZipPageLoader(file: File) : PageLoader { class ZipPageLoader(file: File) : PageLoader {
/**
* The zip file to load pages from.
*/
private val zip = ZipFile(file) private val zip = ZipFile(file)
/** override suspend fun getPages(): List<ReaderPage> {
* Returns an observable containing the pages found on this zip archive ordered with a natural return zip.entries.asSequence()
* comparator.
*/
override fun getPages(): List<ReaderPage> {
return zip.entries.toList()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } } .filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) } .sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.mapIndexed { i, entry -> .mapIndexed { i, entry ->
val streamFn = { zip.getInputStream(entry) }
ReaderPage(i).apply { ReaderPage(i).apply {
stream = streamFn stream = { zip.getInputStream(entry) }
} }
} }
.toList()
}
override fun recycle() {
zip.close()
} }
} }

View File

@@ -0,0 +1,162 @@
package eu.kanade.tachiyomi.source.local.metadata
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.Serializable
import nl.adaptivity.xmlutil.serialization.XmlElement
import nl.adaptivity.xmlutil.serialization.XmlSerialName
import nl.adaptivity.xmlutil.serialization.XmlValue
const val COMIC_INFO_FILE = "ComicInfo.xml"
fun SManga.copyFromComicInfo(comicInfo: ComicInfo) {
comicInfo.series?.let { title = it.value }
comicInfo.writer?.let { author = it.value }
comicInfo.summary?.let { description = it.value }
listOfNotNull(
comicInfo.genre?.value,
comicInfo.tags?.value,
comicInfo.categories?.value
)
.distinct()
.joinToString(", ") { it.trim() }
.takeIf { it.isNotEmpty() }
?.let { genre = it }
listOfNotNull(
comicInfo.penciller?.value,
comicInfo.inker?.value,
comicInfo.colorist?.value,
comicInfo.letterer?.value,
comicInfo.coverArtist?.value
)
.flatMap { it.split(", ") }
.distinct()
.joinToString(", ") { it.trim() }
.takeIf { it.isNotEmpty() }
?.let { artist = it }
status = ComicInfoPublishingStatus.toSMangaValue(comicInfo.publishingStatus?.value)
}
@Serializable
@XmlSerialName("ComicInfo", "", "")
data class ComicInfo(
val title: Title?,
val series: Series?,
val number: Number?,
val summary: Summary?,
val writer: Writer?,
val penciller: Penciller?,
val inker: Inker?,
val colorist: Colorist?,
val letterer: Letterer?,
val coverArtist: CoverArtist?,
val translator: Translator?,
val genre: Genre?,
val tags: Tags?,
val web: Web?,
val publishingStatus: PublishingStatusTachiyomi?,
val categories: CategoriesTachiyomi?
) {
@Suppress("UNUSED")
@XmlElement(false)
@XmlSerialName("xmlns:xsd", "", "")
val xmlSchema: String = "http://www.w3.org/2001/XMLSchema"
@Suppress("UNUSED")
@XmlElement(false)
@XmlSerialName("xmlns:xsi", "", "")
val xmlSchemaInstance: String = "http://www.w3.org/2001/XMLSchema-instance"
@Serializable
@XmlSerialName("Title", "", "")
data class Title(@XmlValue(true) val value: String = "")
@Serializable
@XmlSerialName("Series", "", "")
data class Series(@XmlValue(true) val value: String = "")
@Serializable
@XmlSerialName("Number", "", "")
data class Number(@XmlValue(true) val value: String = "")
@Serializable
@XmlSerialName("Summary", "", "")
data class Summary(@XmlValue(true) val value: String = "")
@Serializable
@XmlSerialName("Writer", "", "")
data class Writer(@XmlValue(true) val value: String = "")
@Serializable
@XmlSerialName("Penciller", "", "")
data class Penciller(@XmlValue(true) val value: String = "")
@Serializable
@XmlSerialName("Inker", "", "")
data class Inker(@XmlValue(true) val value: String = "")
@Serializable
@XmlSerialName("Colorist", "", "")
data class Colorist(@XmlValue(true) val value: String = "")
@Serializable
@XmlSerialName("Letterer", "", "")
data class Letterer(@XmlValue(true) val value: String = "")
@Serializable
@XmlSerialName("CoverArtist", "", "")
data class CoverArtist(@XmlValue(true) val value: String = "")
@Serializable
@XmlSerialName("Translator", "", "")
data class Translator(@XmlValue(true) val value: String = "")
@Serializable
@XmlSerialName("Genre", "", "")
data class Genre(@XmlValue(true) val value: String = "")
@Serializable
@XmlSerialName("Tags", "", "")
data class Tags(@XmlValue(true) val value: String = "")
@Serializable
@XmlSerialName("Web", "", "")
data class Web(@XmlValue(true) val value: String = "")
// The spec doesn't have a good field for this
@Serializable
@XmlSerialName("PublishingStatusTachiyomi", "http://www.w3.org/2001/XMLSchema", "ty")
data class PublishingStatusTachiyomi(@XmlValue(true) val value: String = "")
@Serializable
@XmlSerialName("Categories", "http://www.w3.org/2001/XMLSchema", "ty")
data class CategoriesTachiyomi(@XmlValue(true) val value: String = "")
}
enum class ComicInfoPublishingStatus(
val comicInfoValue: String,
val sMangaModelValue: Int
) {
ONGOING("Ongoing", SManga.ONGOING),
COMPLETED("Completed", SManga.COMPLETED),
LICENSED("Licensed", SManga.LICENSED),
PUBLISHING_FINISHED("Publishing finished", SManga.PUBLISHING_FINISHED),
CANCELLED("Cancelled", SManga.CANCELLED),
ON_HIATUS("On hiatus", SManga.ON_HIATUS),
UNKNOWN("Unknown", SManga.UNKNOWN)
;
companion object {
fun toComicInfoValue(value: Long): String {
return entries.firstOrNull { it.sMangaModelValue == value.toInt() }?.comicInfoValue
?: UNKNOWN.comicInfoValue
}
fun toSMangaValue(value: String?): Int {
return entries.firstOrNull { it.comicInfoValue == value }?.sMangaModelValue
?: UNKNOWN.sMangaModelValue
}
}
}

View File

@@ -0,0 +1,60 @@
package eu.kanade.tachiyomi.source.local.metadata
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.storage.EpubFile
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
/**
* Fills manga metadata using this epub file's metadata.
*/
fun EpubFile.fillMangaMetadata(manga: SManga) {
val ref = getPackageHref()
val doc = getPackageDocument(ref)
val creator = doc.getElementsByTag("dc:creator").first()
val description = doc.getElementsByTag("dc:description").first()
manga.author = creator?.text()
manga.description = description?.text()
}
/**
* Fills chapter metadata using this epub file's metadata.
*/
fun EpubFile.fillChapterMetadata(chapter: SChapter) {
val ref = getPackageHref()
val doc = getPackageDocument(ref)
val title = doc.getElementsByTag("dc:title").first()
val publisher = doc.getElementsByTag("dc:publisher").first()
val creator = doc.getElementsByTag("dc:creator").first()
var date = doc.getElementsByTag("dc:date").first()
if (date == null) {
date = doc.select("meta[property=dcterms:modified]").first()
}
if (title != null) {
chapter.name = title.text()
}
if (publisher != null) {
chapter.scanlator = publisher.text()
} else if (creator != null) {
chapter.scanlator = creator.text()
}
if (date != null) {
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault())
try {
val parsedDate = dateFormat.parse(date.text())
if (parsedDate != null) {
chapter.date_upload = parsedDate.time
}
} catch (e: ParseException) {
// Empty
}
}
}

View File

@@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.source.local.metadata
import kotlinx.serialization.Serializable
@Serializable
class MangaDetails(
val title: String? = null,
val author: String? = null,
val artist: String? = null,
val description: String? = null,
val genre: List<String>? = null,
val status: Int? = null
)

View File

@@ -1,111 +1,78 @@
package eu.kanade.tachiyomi.util.chapter package eu.kanade.tachiyomi.util.chapter
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
/** /**
* -R> = regex conversion. * -R> = regex conversion.
*/ */
object ChapterRecognition { object ChapterRecognition {
private const val NUMBER_PATTERN = """([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?"""
/** /**
* All cases with Ch.xx * All cases with Ch.xx
* Mokushiroku Alice Vol.1 Ch. 4: Misrepresentation -R> 4 * Mokushiroku Alice Vol.1 Ch. 4: Misrepresentation -R> 4
*/ */
private val basic = Regex("""(?<=ch\.) *([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?""") private val basic = Regex("""(?<=ch\.) *$NUMBER_PATTERN""")
/** /**
* Regex used when only one number occurrence
* Example: Bleach 567: Down With Snowwhite -R> 567 * Example: Bleach 567: Down With Snowwhite -R> 567
*/ */
private val occurrence = Regex("""([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?""") private val number = Regex(NUMBER_PATTERN)
/**
* Regex used when manga title removed
* Example: Solanin 028 Vol. 2 -> 028 Vol.2 -> 028Vol.2 -R> 028
*/
private val withoutManga = Regex("""^([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?""")
/** /**
* Regex used to remove unwanted tags * Regex used to remove unwanted tags
* Example Prison School 12 v.1 vol004 version1243 volume64 -R> Prison School 12 * Example Prison School 12 v.1 vol004 version1243 volume64 -R> Prison School 12
*/ */
private val unwanted = Regex("""(?<![a-z])(v|ver|vol|version|volume|season|s).?[0-9]+""") private val unwanted = Regex("""\b(?:v|ver|vol|version|volume|season|s)[^a-z]?[0-9]+""")
/** /**
* Regex used to remove unwanted whitespace * Regex used to remove unwanted whitespace
* Example One Piece 12 special -R> One Piece 12special * Example One Piece 12 special -R> One Piece 12special
*/ */
private val unwantedWhiteSpace = Regex("""(\s)(extra|special|omake)""") private val unwantedWhiteSpace = Regex("""\s(?=extra|special|omake)""")
fun parseChapterNumber(chapter: SChapter, manga: SManga) { fun parseChapterNumber(mangaTitle: String, chapterName: String, chapterNumber: Double? = null): Double {
// If chapter number is known return. // If chapter number is known return.
if (chapter.chapter_number == -2f || chapter.chapter_number > -1f) { if (chapterNumber != null && (chapterNumber == -2.0 || chapterNumber > -1.0)) {
return return chapterNumber
} }
// Get chapter title with lower case // Get chapter title with lower case
var name = chapter.name.lowercase() var name = chapterName.lowercase()
// Remove comma's from chapter.
name = name.replace(',', '.')
// Remove unwanted white spaces.
unwantedWhiteSpace.findAll(name).let {
it.forEach { occurrence -> name = name.replace(occurrence.value, occurrence.value.trim()) }
}
// Remove unwanted tags.
unwanted.findAll(name).let {
it.forEach { occurrence -> name = name.replace(occurrence.value, "") }
}
// Check base case ch.xx
if (updateChapter(basic.find(name), chapter)) {
return
}
// Check one number occurrence.
val occurrences: MutableList<MatchResult> = arrayListOf()
occurrence.findAll(name).let {
it.forEach { occurrence -> occurrences.add(occurrence) }
}
if (occurrences.size == 1) {
if (updateChapter(occurrences[0], chapter)) {
return
}
}
// Remove manga title from chapter title. // Remove manga title from chapter title.
val nameWithoutManga = name.replace(manga.title.lowercase(), "").trim() name = name.replace(mangaTitle.lowercase(), "").trim()
// Check if first value is number after title remove. // Remove comma's or hyphens.
if (updateChapter(withoutManga.find(nameWithoutManga), chapter)) { name = name.replace(',', '.').replace('-', '.')
return
} // Remove unwanted white spaces.
name = unwantedWhiteSpace.replace(name, "")
// Remove unwanted tags.
name = unwanted.replace(name, "")
// Check base case ch.xx
basic.find(name)?.let { return getChapterNumberFromMatch(it) }
// Take the first number encountered. // Take the first number encountered.
if (updateChapter(occurrence.find(nameWithoutManga), chapter)) { number.find(name)?.let { return getChapterNumberFromMatch(it) }
return
} return chapterNumber ?: -1.0
} }
/** /**
* Check if volume is found and update chapter * Check if chapter number is found and return it
* @param match result of regex * @param match result of regex
* @param chapter chapter object * @return chapter number if found else null
* @return true if volume is found
*/ */
private fun updateChapter(match: MatchResult?, chapter: SChapter): Boolean { private fun getChapterNumberFromMatch(match: MatchResult): Double {
match?.let { return match.let {
val initial = it.groups[1]?.value?.toFloat()!! val initial = it.groups[1]?.value?.toDouble()!!
val subChapterDecimal = it.groups[2]?.value val subChapterDecimal = it.groups[2]?.value
val subChapterAlpha = it.groups[3]?.value val subChapterAlpha = it.groups[3]?.value
val addition = checkForDecimal(subChapterDecimal, subChapterAlpha) val addition = checkForDecimal(subChapterDecimal, subChapterAlpha)
chapter.chapter_number = initial.plus(addition) initial.plus(addition)
return true
} }
return false
} }
/** /**
@@ -114,39 +81,39 @@ object ChapterRecognition {
* @param alpha alpha value of regex * @param alpha alpha value of regex
* @return decimal/alpha float value * @return decimal/alpha float value
*/ */
private fun checkForDecimal(decimal: String?, alpha: String?): Float { private fun checkForDecimal(decimal: String?, alpha: String?): Double {
if (!decimal.isNullOrEmpty()) { if (!decimal.isNullOrEmpty()) {
return decimal.toFloat() return decimal.toDouble()
} }
if (!alpha.isNullOrEmpty()) { if (!alpha.isNullOrEmpty()) {
if (alpha.contains("extra")) { if (alpha.contains("extra")) {
return .99f return 0.99
} }
if (alpha.contains("omake")) { if (alpha.contains("omake")) {
return .98f return 0.98
} }
if (alpha.contains("special")) { if (alpha.contains("special")) {
return .97f return 0.97
} }
return if (alpha[0] == '.') { val trimmedAlpha = alpha.trimStart('.')
// Take value after (.) if (trimmedAlpha.length == 1) {
parseAlphaPostFix(alpha[1]) return parseAlphaPostFix(trimmedAlpha[0])
} else {
parseAlphaPostFix(alpha[0])
} }
} }
return .0f return 0.0
} }
/** /**
* x.a -> x.1, x.b -> x.2, etc * x.a -> x.1, x.b -> x.2, etc
*/ */
private fun parseAlphaPostFix(alpha: Char): Float { private fun parseAlphaPostFix(alpha: Char): Double {
return ("0." + (alpha.code - 96).toString()).toFloat() val number = alpha.code - ('a'.code - 1)
if (number >= 10) return 0.0
return number / 10.0
} }
} }

View File

@@ -1,7 +1,5 @@
package eu.kanade.tachiyomi.util.storage package eu.kanade.tachiyomi.util.storage
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import org.apache.commons.compress.archivers.zip.ZipArchiveEntry import org.apache.commons.compress.archivers.zip.ZipArchiveEntry
import org.apache.commons.compress.archivers.zip.ZipFile import org.apache.commons.compress.archivers.zip.ZipFile
import org.jsoup.Jsoup import org.jsoup.Jsoup
@@ -9,9 +7,6 @@ import org.jsoup.nodes.Document
import java.io.Closeable import java.io.Closeable
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
/** /**
* Wrapper over ZipFile to load files in epub format. * Wrapper over ZipFile to load files in epub format.
@@ -49,58 +44,6 @@ class EpubFile(file: File) : Closeable {
return zip.getEntry(name) return zip.getEntry(name)
} }
/**
* Fills manga metadata using this epub file's metadata.
*/
fun fillMangaMetadata(manga: SManga) {
val ref = getPackageHref()
val doc = getPackageDocument(ref)
val creator = doc.getElementsByTag("dc:creator").first()
val description = doc.getElementsByTag("dc:description").first()
manga.author = creator?.text()
manga.description = description?.text()
}
/**
* Fills chapter metadata using this epub file's metadata.
*/
fun fillChapterMetadata(chapter: SChapter) {
val ref = getPackageHref()
val doc = getPackageDocument(ref)
val title = doc.getElementsByTag("dc:title").first()
val publisher = doc.getElementsByTag("dc:publisher").first()
val creator = doc.getElementsByTag("dc:creator").first()
var date = doc.getElementsByTag("dc:date").first()
if (date == null) {
date = doc.select("meta[property=dcterms:modified]").first()
}
if (title != null) {
chapter.name = title.text()
}
if (publisher != null) {
chapter.scanlator = publisher.text()
} else if (creator != null) {
chapter.scanlator = creator.text()
}
if (date != null) {
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault())
try {
val parsedDate = dateFormat.parse(date.text())
if (parsedDate != null) {
chapter.date_upload = parsedDate.time
}
} catch (e: ParseException) {
// Empty
}
}
}
/** /**
* Returns the path of all the images found in the epub file. * Returns the path of all the images found in the epub file.
*/ */
@@ -114,7 +57,7 @@ class EpubFile(file: File) : Closeable {
/** /**
* Returns the path to the package document. * Returns the path to the package document.
*/ */
private fun getPackageHref(): String { fun getPackageHref(): String {
val meta = zip.getEntry(resolveZipPath("META-INF", "container.xml")) val meta = zip.getEntry(resolveZipPath("META-INF", "container.xml"))
if (meta != null) { if (meta != null) {
val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") } val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") }
@@ -129,7 +72,7 @@ class EpubFile(file: File) : Closeable {
/** /**
* Returns the package document where all the files are listed. * Returns the package document where all the files are listed.
*/ */
private fun getPackageDocument(ref: String): Document { fun getPackageDocument(ref: String): Document {
val entry = zip.getEntry(ref) val entry = zip.getEntry(ref)
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") } return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
} }
@@ -137,9 +80,9 @@ class EpubFile(file: File) : Closeable {
/** /**
* Returns all the pages from the epub. * Returns all the pages from the epub.
*/ */
private fun getPagesFromDocument(document: Document): List<String> { fun getPagesFromDocument(document: Document): List<String> {
val pages = document.select("manifest > item") val pages = document.select("manifest > item")
.filter { element -> "application/xhtml+xml" == element.attr("media-type") } .filter { node -> "application/xhtml+xml" == node.attr("media-type") }
.associateBy { it.attr("id") } .associateBy { it.attr("id") }
val spine = document.select("spine > itemref").map { it.attr("idref") } val spine = document.select("spine > itemref").map { it.attr("idref") }

View File

@@ -11,7 +11,6 @@ import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.graphql.types.ChapterMetaType import suwayomi.tachidesk.graphql.types.ChapterMetaType
import suwayomi.tachidesk.graphql.types.ChapterType import suwayomi.tachidesk.graphql.types.ChapterType
import suwayomi.tachidesk.manga.impl.Chapter import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
import suwayomi.tachidesk.manga.model.table.ChapterTable import suwayomi.tachidesk.manga.model.table.ChapterTable
@@ -213,12 +212,12 @@ class ChapterMutation {
val source = getCatalogueSourceOrNull(manga[MangaTable.sourceReference])!! val source = getCatalogueSourceOrNull(manga[MangaTable.sourceReference])!!
return future { return future {
source.fetchPageList( source.getPageList(
SChapter.create().apply { SChapter.create().apply {
url = chapter[ChapterTable.url] url = chapter[ChapterTable.url]
name = chapter[ChapterTable.name] name = chapter[ChapterTable.name]
} }
).awaitSingle() )
}.thenApply { pageList -> }.thenApply { pageList ->
transaction { transaction {
PageTable.deleteWhere { PageTable.chapter eq chapterId } PageTable.deleteWhere { PageTable.chapter eq chapterId }

View File

@@ -28,7 +28,6 @@ import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.Manga.getManga import suwayomi.tachidesk.manga.impl.Manga.getManga
import suwayomi.tachidesk.manga.impl.download.DownloadManager import suwayomi.tachidesk.manga.impl.download.DownloadManager
import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput import suwayomi.tachidesk.manga.impl.download.DownloadManager.EnqueueInput
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass
@@ -118,12 +117,13 @@ object Chapter {
} }
val numberOfCurrentChapters = getCountOfMangaChapters(mangaId) val numberOfCurrentChapters = getCountOfMangaChapters(mangaId)
val chapterList = source.fetchChapterList(sManga).awaitSingle() val chapterList = source.getChapterList(sManga)
// Recognize number for new chapters. // Recognize number for new chapters.
chapterList.forEach { chapterList.forEach { chapter ->
(source as? HttpSource)?.prepareNewChapter(it, sManga) (source as? HttpSource)?.prepareNewChapter(chapter, sManga)
ChapterRecognition.parseChapterNumber(it, sManga) val chapterNumber = ChapterRecognition.parseChapterNumber(manga.title, chapter.name, chapter.chapter_number.toDouble())
chapter.chapter_number = chapterNumber.toFloat()
} }
var now = Instant.now().epochSecond var now = Instant.now().epochSecond

View File

@@ -25,7 +25,6 @@ import org.kodein.di.conf.global
import org.kodein.di.instance import org.kodein.di.instance
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
import suwayomi.tachidesk.manga.impl.Source.getSource import suwayomi.tachidesk.manga.impl.Source.getSource
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.network.await import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrNull
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
@@ -105,7 +104,7 @@ object Manga {
url = mangaEntry[MangaTable.url] url = mangaEntry[MangaTable.url]
title = mangaEntry[MangaTable.title] title = mangaEntry[MangaTable.title]
} }
val networkManga = source.fetchMangaDetails(sManga).awaitSingle() val networkManga = source.getMangaDetails(sManga)
sManga.copyFrom(networkManga) sManga.copyFrom(networkManga)
transaction { transaction {

View File

@@ -18,7 +18,6 @@ import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.Page.getPageName import suwayomi.tachidesk.manga.impl.Page.getPageName
import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath import suwayomi.tachidesk.manga.impl.util.getChapterCbzPath
import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath import suwayomi.tachidesk.manga.impl.util.getChapterDownloadPath
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
@@ -64,12 +63,12 @@ private class ChapterForDownload(
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() } val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference]) val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
return source.fetchPageList( return source.getPageList(
SChapter.create().apply { SChapter.create().apply {
url = chapterEntry[ChapterTable.url] url = chapterEntry[ChapterTable.url]
name = chapterEntry[ChapterTable.name] name = chapterEntry[ChapterTable.name]
} }
).awaitSingle() )
} }
private fun markAsNotDownloaded() { private fun markAsNotDownloaded() {