Migrate LocalMangaInfo to Okio
parent
ad0452486f
commit
9425d29596
@ -1,159 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.local.data.input
|
|
||||||
|
|
||||||
import androidx.core.net.toFile
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
|
||||||
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.creationTime
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.longHashCode
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toListSorted
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.walkCompat
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.withChildren
|
|
||||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
|
||||||
import org.koitharu.kotatsu.local.data.hasCbzExtension
|
|
||||||
import org.koitharu.kotatsu.local.data.hasImageExtension
|
|
||||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
|
||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
|
||||||
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
|
||||||
import java.io.File
|
|
||||||
import java.util.TreeMap
|
|
||||||
import java.util.zip.ZipFile
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manga {Folder}
|
|
||||||
* |--- index.json (optional)
|
|
||||||
* |--- Chapter 1.cbz
|
|
||||||
* |--- Page 1.png
|
|
||||||
* :
|
|
||||||
* L--- Page x.png
|
|
||||||
* |--- Chapter 2.cbz
|
|
||||||
* :
|
|
||||||
* L--- Chapter x.cbz
|
|
||||||
*/
|
|
||||||
class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
|
|
||||||
|
|
||||||
override suspend fun getManga(): LocalManga = runInterruptible(Dispatchers.IO) {
|
|
||||||
val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX))
|
|
||||||
val mangaUri = root.toUri().toString()
|
|
||||||
val chapterFiles = getChaptersFiles()
|
|
||||||
val info = index?.getMangaInfo()
|
|
||||||
val cover = fileUri(
|
|
||||||
root,
|
|
||||||
index?.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
|
|
||||||
)
|
|
||||||
val manga = info?.copy2(
|
|
||||||
source = LocalMangaSource,
|
|
||||||
url = mangaUri,
|
|
||||||
coverUrl = cover,
|
|
||||||
largeCoverUrl = cover,
|
|
||||||
chapters = info.chapters?.mapIndexedNotNull { i, c ->
|
|
||||||
val fileName = index.getChapterFileName(c.id)
|
|
||||||
val file = if (fileName != null) {
|
|
||||||
chapterFiles[fileName]
|
|
||||||
} else {
|
|
||||||
// old downloads
|
|
||||||
chapterFiles.values.elementAtOrNull(i)
|
|
||||||
} ?: return@mapIndexedNotNull null
|
|
||||||
c.copy(url = file.toUri().toString(), source = LocalMangaSource)
|
|
||||||
},
|
|
||||||
) ?: Manga(
|
|
||||||
id = root.absolutePath.longHashCode(),
|
|
||||||
title = root.name.toHumanReadable(),
|
|
||||||
url = mangaUri,
|
|
||||||
publicUrl = mangaUri,
|
|
||||||
source = LocalMangaSource,
|
|
||||||
coverUrl = findFirstImageEntry().orEmpty(),
|
|
||||||
chapters = chapterFiles.values.mapIndexed { i, f ->
|
|
||||||
MangaChapter(
|
|
||||||
id = "$i${f.name}".longHashCode(),
|
|
||||||
name = f.nameWithoutExtension.toHumanReadable(),
|
|
||||||
number = 0f,
|
|
||||||
volume = 0,
|
|
||||||
source = LocalMangaSource,
|
|
||||||
uploadDate = f.creationTime,
|
|
||||||
url = f.toUri().toString(),
|
|
||||||
scanlator = null,
|
|
||||||
branch = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
altTitle = null,
|
|
||||||
rating = -1f,
|
|
||||||
isNsfw = false,
|
|
||||||
tags = setOf(),
|
|
||||||
state = null,
|
|
||||||
author = null,
|
|
||||||
largeCoverUrl = null,
|
|
||||||
description = null,
|
|
||||||
)
|
|
||||||
LocalManga(manga, root)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
|
|
||||||
val index = MangaIndex.read(File(root, LocalMangaOutput.ENTRY_NAME_INDEX))
|
|
||||||
index?.getMangaInfo()
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
|
|
||||||
val file = chapter.url.toUri().toFile()
|
|
||||||
if (file.isDirectory) {
|
|
||||||
file.withChildren { children ->
|
|
||||||
children
|
|
||||||
.filter { it.isFile && hasImageExtension(it) }
|
|
||||||
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
|
|
||||||
}.map {
|
|
||||||
val pageUri = it.toUri().toString()
|
|
||||||
MangaPage(pageUri.longHashCode(), pageUri, null, LocalMangaSource)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ZipFile(file).use { zip ->
|
|
||||||
zip.entries()
|
|
||||||
.asSequence()
|
|
||||||
.filter { x -> !x.isDirectory }
|
|
||||||
.map { it.name }
|
|
||||||
.toListSorted(AlphanumComparator())
|
|
||||||
.map {
|
|
||||||
val pageUri = zipUri(file, it)
|
|
||||||
MangaPage(
|
|
||||||
id = pageUri.longHashCode(),
|
|
||||||
url = pageUri,
|
|
||||||
preview = null,
|
|
||||||
source = LocalMangaSource,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
|
|
||||||
|
|
||||||
private fun getChaptersFiles() = root.walkCompat(includeDirectories = true)
|
|
||||||
.filter { it != root && it.isChapterDirectory() || it.hasCbzExtension() }
|
|
||||||
.associateByTo(TreeMap(AlphanumComparator())) { it.name }
|
|
||||||
|
|
||||||
private fun findFirstImageEntry(): String? {
|
|
||||||
return root.walkCompat(includeDirectories = false)
|
|
||||||
.firstOrNull { hasImageExtension(it) }?.toUri()?.toString()
|
|
||||||
?: run {
|
|
||||||
val cbz = root.walkCompat(includeDirectories = false)
|
|
||||||
.firstOrNull { it.hasCbzExtension() } ?: return null
|
|
||||||
ZipFile(cbz).use { zip ->
|
|
||||||
zip.entries().asSequence()
|
|
||||||
.firstOrNull { !it.isDirectory && hasImageExtension(it.name) }
|
|
||||||
?.let { zipUri(cbz, it.name) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fileUri(base: File, name: String): String {
|
|
||||||
return File(base, name).toUri().toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun File.isChapterDirectory(): Boolean {
|
|
||||||
return isDirectory && withChildren { children -> children.any { hasImageExtension(it) } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.local.data.input
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.core.net.toFile
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.channelFlow
|
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toZipUri
|
|
||||||
import org.koitharu.kotatsu.local.data.hasCbzExtension
|
|
||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaSource
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
sealed class LocalMangaInput(
|
|
||||||
protected val root: File,
|
|
||||||
) {
|
|
||||||
|
|
||||||
abstract suspend fun getManga(): LocalManga
|
|
||||||
|
|
||||||
abstract suspend fun getMangaInfo(): Manga?
|
|
||||||
|
|
||||||
abstract suspend fun getPages(chapter: MangaChapter): List<MangaPage>
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun of(manga: Manga): LocalMangaInput = of(Uri.parse(manga.url).toFile())
|
|
||||||
|
|
||||||
fun of(chapter: MangaChapter): LocalMangaInput = of(Uri.parse(chapter.url).toFile())
|
|
||||||
|
|
||||||
fun of(file: File): LocalMangaInput = when {
|
|
||||||
file.isDirectory -> LocalMangaDirInput(file)
|
|
||||||
else -> LocalMangaZipInput(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ofOrNull(file: File): LocalMangaInput? = when {
|
|
||||||
file.isDirectory -> LocalMangaDirInput(file)
|
|
||||||
hasCbzExtension(file.name) -> LocalMangaZipInput(file)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun find(roots: Iterable<File>, manga: Manga): LocalMangaInput? = channelFlow {
|
|
||||||
val fileName = manga.title.toFileNameSafe()
|
|
||||||
for (root in roots) {
|
|
||||||
launch {
|
|
||||||
val dir = File(root, fileName)
|
|
||||||
val zip = File(root, "$fileName.cbz")
|
|
||||||
val input = when {
|
|
||||||
dir.isDirectory -> LocalMangaDirInput(dir)
|
|
||||||
zip.isFile -> LocalMangaZipInput(zip)
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
val info = runCatchingCancellable { input?.getMangaInfo() }.getOrNull()
|
|
||||||
if (info?.id == manga.id) {
|
|
||||||
send(input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.flowOn(Dispatchers.Default).firstOrNull()
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
protected fun zipUri(file: File, entryName: String): String = file.toZipUri(entryName).toString()
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
protected fun Manga.copy2(
|
|
||||||
url: String,
|
|
||||||
coverUrl: String,
|
|
||||||
largeCoverUrl: String,
|
|
||||||
chapters: List<MangaChapter>?,
|
|
||||||
source: MangaSource,
|
|
||||||
) = Manga(
|
|
||||||
id = id,
|
|
||||||
title = title,
|
|
||||||
altTitle = altTitle,
|
|
||||||
url = url,
|
|
||||||
publicUrl = publicUrl,
|
|
||||||
rating = rating,
|
|
||||||
isNsfw = isNsfw,
|
|
||||||
coverUrl = coverUrl,
|
|
||||||
tags = tags,
|
|
||||||
state = state,
|
|
||||||
author = author,
|
|
||||||
largeCoverUrl = largeCoverUrl,
|
|
||||||
description = description,
|
|
||||||
chapters = chapters,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
protected fun MangaChapter.copy(
|
|
||||||
url: String,
|
|
||||||
source: MangaSource,
|
|
||||||
) = MangaChapter(
|
|
||||||
id = id,
|
|
||||||
name = name,
|
|
||||||
number = number,
|
|
||||||
volume = volume,
|
|
||||||
url = url,
|
|
||||||
scanlator = scanlator,
|
|
||||||
uploadDate = uploadDate,
|
|
||||||
branch = branch,
|
|
||||||
source = source,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,309 @@
|
|||||||
|
package org.koitharu.kotatsu.local.data.input
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import okio.FileSystem
|
||||||
|
import okio.Path
|
||||||
|
import okio.Path.Companion.toOkioPath
|
||||||
|
import okio.Path.Companion.toPath
|
||||||
|
import okio.openZip
|
||||||
|
import org.jetbrains.annotations.Blocking
|
||||||
|
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
||||||
|
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isFileUri
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isRegularFile
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isZipUri
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.longHashCode
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toListSorted
|
||||||
|
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||||
|
import org.koitharu.kotatsu.local.data.isZipArchive
|
||||||
|
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput.Companion.ENTRY_NAME_INDEX
|
||||||
|
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manga root {dir or zip file}
|
||||||
|
* |--- index.json (optional)
|
||||||
|
* |--- Page 1.png
|
||||||
|
* |--- Page 2.png
|
||||||
|
* |---Chapter 1/(dir or zip, optional)
|
||||||
|
* |------Page 1.1.png
|
||||||
|
* :
|
||||||
|
* L--- Page x.png
|
||||||
|
*/
|
||||||
|
class LocalMangaParser(private val uri: Uri) {
|
||||||
|
|
||||||
|
constructor(file: File) : this(file.toUri())
|
||||||
|
|
||||||
|
private val rootFile: File = File(uri.schemeSpecificPart)
|
||||||
|
|
||||||
|
suspend fun getManga(withDetails: Boolean): LocalManga = runInterruptible(Dispatchers.IO) {
|
||||||
|
val (fileSystem, rootPath) = uri.resolveFsAndPath()
|
||||||
|
val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
|
||||||
|
val mangaInfo = index?.getMangaInfo()
|
||||||
|
if (mangaInfo != null) {
|
||||||
|
val coverEntry: Path? = index.getCoverEntry()?.let { rootPath / it } ?: fileSystem.findFirstImage(rootPath)
|
||||||
|
mangaInfo.copyInternal(
|
||||||
|
source = LocalMangaSource,
|
||||||
|
url = rootFile.toUri().toString(),
|
||||||
|
coverUrl = coverEntry?.let { uri.child(it, resolve = true).toString() }.orEmpty(),
|
||||||
|
largeCoverUrl = null,
|
||||||
|
chapters = if (withDetails) {
|
||||||
|
mangaInfo.chapters?.map { c ->
|
||||||
|
c.copyInternal(
|
||||||
|
url = index.getChapterFileName(c.id)?.toPath()?.let {
|
||||||
|
uri.child(it, resolve = false).toString()
|
||||||
|
} ?: uri.toString(),
|
||||||
|
source = LocalMangaSource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val title = rootFile.nameWithoutExtension.replace("_", " ").toCamelCase()
|
||||||
|
val coverEntry = fileSystem.findFirstImage(rootPath)
|
||||||
|
val mimeTypeMap = MimeTypeMap.getSingleton()
|
||||||
|
Manga(
|
||||||
|
id = rootFile.absolutePath.longHashCode(),
|
||||||
|
title = title,
|
||||||
|
url = rootFile.toUri().toString(),
|
||||||
|
publicUrl = rootFile.toUri().toString(),
|
||||||
|
source = LocalMangaSource,
|
||||||
|
coverUrl = coverEntry?.let {
|
||||||
|
uri.child(it, resolve = true).toString()
|
||||||
|
}.orEmpty(),
|
||||||
|
chapters = if (withDetails) {
|
||||||
|
val chapters = fileSystem.listRecursively(rootPath)
|
||||||
|
.mapNotNullTo(HashSet()) { path ->
|
||||||
|
if (path != coverEntry && fileSystem.isRegularFile(path) && mimeTypeMap.isImage(path)) {
|
||||||
|
path.parent
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.sortedWith(compareBy(AlphanumComparator()) { x -> x.toString() })
|
||||||
|
chapters.mapIndexed { i, p ->
|
||||||
|
val s = if (p.root == rootPath.root) {
|
||||||
|
p.relativeTo(rootPath).toString()
|
||||||
|
} else {
|
||||||
|
p
|
||||||
|
}.toString().removePrefix(Path.DIRECTORY_SEPARATOR)
|
||||||
|
MangaChapter(
|
||||||
|
id = "$i$s".longHashCode(),
|
||||||
|
name = s.ifEmpty { title },
|
||||||
|
number = 0f,
|
||||||
|
volume = 0,
|
||||||
|
source = LocalMangaSource,
|
||||||
|
uploadDate = 0L,
|
||||||
|
url = uri.child(p.relativeTo(rootPath), resolve = false).toString(),
|
||||||
|
scanlator = null,
|
||||||
|
branch = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
altTitle = null,
|
||||||
|
rating = -1f,
|
||||||
|
isNsfw = false,
|
||||||
|
tags = setOf(),
|
||||||
|
state = null,
|
||||||
|
author = null,
|
||||||
|
largeCoverUrl = null,
|
||||||
|
description = null,
|
||||||
|
)
|
||||||
|
}.let { LocalManga(it, rootFile) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
|
||||||
|
val (fileSystem, rootPath) = uri.resolveFsAndPath()
|
||||||
|
val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
|
||||||
|
index?.getMangaInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getPages(chapter: MangaChapter): List<MangaPage> = runInterruptible(Dispatchers.IO) {
|
||||||
|
val chapterUri = chapter.url.toUri().resolve()
|
||||||
|
val (fileSystem, rootPath) = chapterUri.resolveFsAndPath()
|
||||||
|
val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
|
||||||
|
val entries = fileSystem.listRecursively(rootPath)
|
||||||
|
.filter { fileSystem.isRegularFile(it) }
|
||||||
|
if (index != null) {
|
||||||
|
val pattern = index.getChapterNamesPattern(chapter)
|
||||||
|
entries.filter { x -> x.name.substringBefore('.').matches(pattern) }
|
||||||
|
} else {
|
||||||
|
val mimeTypeMap = MimeTypeMap.getSingleton()
|
||||||
|
entries.filter { x ->
|
||||||
|
mimeTypeMap.isImage(x) && x.parent == rootPath
|
||||||
|
}
|
||||||
|
}.toListSorted(compareBy(AlphanumComparator()) { x -> x.toString() })
|
||||||
|
.map { x ->
|
||||||
|
val entryUri = chapterUri.child(x, resolve = true).toString()
|
||||||
|
MangaPage(
|
||||||
|
id = entryUri.longHashCode(),
|
||||||
|
url = entryUri,
|
||||||
|
preview = null,
|
||||||
|
source = LocalMangaSource,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Uri.child(path: Path, resolve: Boolean): Uri {
|
||||||
|
val builder = buildUpon()
|
||||||
|
if (isZipUri() || !resolve) {
|
||||||
|
builder.fragment(path.toString().removePrefix(Path.DIRECTORY_SEPARATOR))
|
||||||
|
} else {
|
||||||
|
val file = toFile()
|
||||||
|
if (file.isZipArchive) {
|
||||||
|
builder.fragment(path.toString().removePrefix(Path.DIRECTORY_SEPARATOR))
|
||||||
|
builder.scheme(URI_SCHEME_ZIP)
|
||||||
|
} else {
|
||||||
|
builder.appendEncodedPath(path.relativeTo(file.toOkioPath()).toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
fun getOrNull(file: File): LocalMangaParser? = if ((file.isDirectory || file.isZipArchive) && file.canRead()) {
|
||||||
|
LocalMangaParser(file)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun find(roots: Iterable<File>, manga: Manga): LocalMangaParser? = channelFlow {
|
||||||
|
val fileName = manga.title.toFileNameSafe()
|
||||||
|
for (root in roots) {
|
||||||
|
launch {
|
||||||
|
val parser = getOrNull(File(root, fileName)) ?: getOrNull(File(root, "$fileName.cbz"))
|
||||||
|
val info = runCatchingCancellable { parser?.getMangaInfo() }.getOrNull()
|
||||||
|
if (info?.id == manga.id) {
|
||||||
|
send(parser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.flowOn(Dispatchers.Default).firstOrNull()
|
||||||
|
|
||||||
|
private fun FileSystem.findFirstImage(rootPath: Path) = findFirstImageImpl(rootPath, false)
|
||||||
|
?: findFirstImageImpl(rootPath, true)
|
||||||
|
|
||||||
|
private fun FileSystem.findFirstImageImpl(
|
||||||
|
rootPath: Path,
|
||||||
|
recursive: Boolean
|
||||||
|
): Path? = runCatchingCancellable {
|
||||||
|
val mimeTypeMap = MimeTypeMap.getSingleton()
|
||||||
|
if (recursive) {
|
||||||
|
listRecursively(rootPath)
|
||||||
|
} else {
|
||||||
|
list(rootPath).asSequence()
|
||||||
|
}.filter { isRegularFile(it) && mimeTypeMap.isImage(it) }
|
||||||
|
.toListSorted(compareBy(AlphanumComparator()) { x -> x.toString() })
|
||||||
|
.firstOrNull()
|
||||||
|
}.onFailure { e ->
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun MimeTypeMap.isImage(path: Path): Boolean =
|
||||||
|
getMimeTypeFromExtension(path.name.substringAfterLast('.'))
|
||||||
|
?.startsWith("image/") == true
|
||||||
|
|
||||||
|
private fun Uri.resolve(): Uri = if (isFileUri()) {
|
||||||
|
val file = toFile()
|
||||||
|
if (file.isZipArchive) {
|
||||||
|
this
|
||||||
|
} else if (file.isDirectory) {
|
||||||
|
file.resolve(fragment.orEmpty()).toUri()
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
|
||||||
|
@Blocking
|
||||||
|
private fun Uri.resolveFsAndPath(): Pair<FileSystem, Path> {
|
||||||
|
val resolved = resolve()
|
||||||
|
return when {
|
||||||
|
resolved.isZipUri() -> {
|
||||||
|
FileSystem.SYSTEM.openZip(resolved.schemeSpecificPart.toPath()) to resolved.fragment.orEmpty()
|
||||||
|
.toRootedPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
isFileUri() -> {
|
||||||
|
val file = toFile()
|
||||||
|
if (file.isZipArchive) {
|
||||||
|
FileSystem.SYSTEM.openZip(schemeSpecificPart.toPath()) to fragment.orEmpty().toRootedPath()
|
||||||
|
} else {
|
||||||
|
FileSystem.SYSTEM to file.toOkioPath()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> throw IllegalArgumentException("Unsupported uri $resolved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toRootedPath(): Path = if (startsWith(Path.DIRECTORY_SEPARATOR)) {
|
||||||
|
this
|
||||||
|
} else {
|
||||||
|
Path.DIRECTORY_SEPARATOR + this
|
||||||
|
}.toPath()
|
||||||
|
|
||||||
|
private fun Manga.copyInternal(
|
||||||
|
url: String = this.url,
|
||||||
|
coverUrl: String = this.coverUrl,
|
||||||
|
largeCoverUrl: String? = this.largeCoverUrl,
|
||||||
|
chapters: List<MangaChapter>? = this.chapters,
|
||||||
|
source: MangaSource = this.source,
|
||||||
|
): Manga = Manga(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
altTitle = altTitle,
|
||||||
|
url = url,
|
||||||
|
publicUrl = publicUrl,
|
||||||
|
rating = rating,
|
||||||
|
isNsfw = isNsfw,
|
||||||
|
coverUrl = coverUrl,
|
||||||
|
tags = tags,
|
||||||
|
state = state,
|
||||||
|
author = author,
|
||||||
|
largeCoverUrl = largeCoverUrl,
|
||||||
|
description = description,
|
||||||
|
chapters = chapters,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun MangaChapter.copyInternal(
|
||||||
|
url: String = this.url,
|
||||||
|
source: MangaSource = this.source,
|
||||||
|
) = MangaChapter(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
number = number,
|
||||||
|
volume = volume,
|
||||||
|
url = url,
|
||||||
|
scanlator = scanlator,
|
||||||
|
uploadDate = uploadDate,
|
||||||
|
branch = branch,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,155 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.local.data.input
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.webkit.MimeTypeMap
|
|
||||||
import androidx.collection.ArraySet
|
|
||||||
import androidx.core.net.toFile
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import org.koitharu.kotatsu.core.model.LocalMangaSource
|
|
||||||
import org.koitharu.kotatsu.core.util.AlphanumComparator
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.longHashCode
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.readText
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toListSorted
|
|
||||||
import org.koitharu.kotatsu.local.data.MangaIndex
|
|
||||||
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
|
||||||
import org.koitharu.kotatsu.local.domain.model.LocalManga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.Manga
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaPage
|
|
||||||
import org.koitharu.kotatsu.parsers.util.toCamelCase
|
|
||||||
import java.io.File
|
|
||||||
import java.util.Enumeration
|
|
||||||
import java.util.zip.ZipEntry
|
|
||||||
import java.util.zip.ZipFile
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manga archive {.cbz or .zip file}
|
|
||||||
* |--- index.json (optional)
|
|
||||||
* |--- Page 1.png
|
|
||||||
* |--- Page 2.png
|
|
||||||
* :
|
|
||||||
* L--- Page x.png
|
|
||||||
*/
|
|
||||||
class LocalMangaZipInput(root: File) : LocalMangaInput(root) {
|
|
||||||
|
|
||||||
override suspend fun getManga(): LocalManga {
|
|
||||||
val manga = runInterruptible(Dispatchers.IO) {
|
|
||||||
ZipFile(root).use { zip ->
|
|
||||||
val fileUri = root.toUri().toString()
|
|
||||||
val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)
|
|
||||||
val index = entry?.let(zip::readText)?.let(::MangaIndex)
|
|
||||||
val info = index?.getMangaInfo()
|
|
||||||
if (info != null) {
|
|
||||||
val cover = zipUri(
|
|
||||||
root,
|
|
||||||
entryName = index.getCoverEntry() ?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
|
|
||||||
)
|
|
||||||
return@use info.copy2(
|
|
||||||
source = LocalMangaSource,
|
|
||||||
url = fileUri,
|
|
||||||
coverUrl = cover,
|
|
||||||
largeCoverUrl = cover,
|
|
||||||
chapters = info.chapters?.map { c ->
|
|
||||||
c.copy(url = fileUri, source = LocalMangaSource)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// fallback
|
|
||||||
val title = root.nameWithoutExtension.replace("_", " ").toCamelCase()
|
|
||||||
val chapters = ArraySet<String>()
|
|
||||||
for (x in zip.entries()) {
|
|
||||||
if (!x.isDirectory) {
|
|
||||||
chapters += x.name.substringBeforeLast(File.separatorChar, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val uriBuilder = root.toUri().buildUpon()
|
|
||||||
Manga(
|
|
||||||
id = root.absolutePath.longHashCode(),
|
|
||||||
title = title,
|
|
||||||
url = fileUri,
|
|
||||||
publicUrl = fileUri,
|
|
||||||
source = LocalMangaSource,
|
|
||||||
coverUrl = zipUri(root, findFirstImageEntry(zip.entries())?.name.orEmpty()),
|
|
||||||
chapters = chapters.sortedWith(AlphanumComparator())
|
|
||||||
.mapIndexed { i, s ->
|
|
||||||
MangaChapter(
|
|
||||||
id = "$i$s".longHashCode(),
|
|
||||||
name = s.ifEmpty { title },
|
|
||||||
number = 0f,
|
|
||||||
volume = 0,
|
|
||||||
source = LocalMangaSource,
|
|
||||||
uploadDate = 0L,
|
|
||||||
url = uriBuilder.fragment(s).build().toString(),
|
|
||||||
scanlator = null,
|
|
||||||
branch = null,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
altTitle = null,
|
|
||||||
rating = -1f,
|
|
||||||
isNsfw = false,
|
|
||||||
tags = setOf(),
|
|
||||||
state = null,
|
|
||||||
author = null,
|
|
||||||
largeCoverUrl = null,
|
|
||||||
description = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return LocalManga(manga, root)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
|
|
||||||
ZipFile(root).use { zip ->
|
|
||||||
val entry = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)
|
|
||||||
val index = entry?.let(zip::readText)?.let(::MangaIndex)
|
|
||||||
index?.getMangaInfo()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
|
|
||||||
return runInterruptible(Dispatchers.IO) {
|
|
||||||
val uri = Uri.parse(chapter.url)
|
|
||||||
val file = uri.toFile()
|
|
||||||
ZipFile(file).use { zip ->
|
|
||||||
val index = zip.getEntry(LocalMangaOutput.ENTRY_NAME_INDEX)?.let(zip::readText)?.let(::MangaIndex)
|
|
||||||
var entries = zip.entries().asSequence()
|
|
||||||
entries = if (index != null) {
|
|
||||||
val pattern = index.getChapterNamesPattern(chapter)
|
|
||||||
entries.filter { x -> !x.isDirectory && x.name.substringBefore('.').matches(pattern) }
|
|
||||||
} else {
|
|
||||||
val parent = uri.fragment.orEmpty()
|
|
||||||
entries.filter { x ->
|
|
||||||
!x.isDirectory && x.name.substringBeforeLast(
|
|
||||||
File.separatorChar,
|
|
||||||
"",
|
|
||||||
) == parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entries
|
|
||||||
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
|
|
||||||
.map { x ->
|
|
||||||
val entryUri = zipUri(file, x.name)
|
|
||||||
MangaPage(
|
|
||||||
id = entryUri.longHashCode(),
|
|
||||||
url = entryUri,
|
|
||||||
preview = null,
|
|
||||||
source = LocalMangaSource,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun findFirstImageEntry(entries: Enumeration<out ZipEntry>): ZipEntry? {
|
|
||||||
val list = entries.toList()
|
|
||||||
.filterNot { it.isDirectory }
|
|
||||||
.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })
|
|
||||||
val map = MimeTypeMap.getSingleton()
|
|
||||||
return list.firstOrNull {
|
|
||||||
map.getMimeTypeFromExtension(it.name.substringAfterLast('.'))
|
|
||||||
?.startsWith("image/") == true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue