Support for storing local manga in directories with multiple chapters cbz
parent
b1a94c0f34
commit
4744a0a162
@ -1,27 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.local.data
|
|
||||||
|
|
||||||
import android.os.FileObserver
|
|
||||||
import java.io.File
|
|
||||||
import kotlinx.coroutines.channels.ProducerScope
|
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
|
||||||
import kotlinx.coroutines.channels.trySendBlocking
|
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
class FlowFileObserver(
|
|
||||||
private val producerScope: ProducerScope<File>,
|
|
||||||
private val file: File,
|
|
||||||
) : FileObserver(file.absolutePath, CREATE or DELETE or CLOSE_WRITE) {
|
|
||||||
|
|
||||||
override fun onEvent(event: Int, path: String?) {
|
|
||||||
producerScope.trySendBlocking(
|
|
||||||
if (path == null) file else file.resolve(path),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun File.observe() = callbackFlow {
|
|
||||||
val observer = FlowFileObserver(this, this@observe)
|
|
||||||
observer.startWatching()
|
|
||||||
awaitClose { observer.stopWatching() }
|
|
||||||
}
|
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package org.koitharu.kotatsu.local.data
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileFilter
|
||||||
|
import java.io.FilenameFilter
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
|
||||||
|
class ImageFileFilter : FilenameFilter, FileFilter {
|
||||||
|
|
||||||
|
override fun accept(dir: File, name: String): Boolean {
|
||||||
|
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
|
return isExtensionValid(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun accept(pathname: File?): Boolean {
|
||||||
|
val ext = pathname?.extension?.lowercase(Locale.ROOT) ?: return false
|
||||||
|
return isExtensionValid(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun accept(entry: ZipEntry): Boolean {
|
||||||
|
val ext = entry.name.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||||
|
return isExtensionValid(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isExtensionValid(ext: String): Boolean {
|
||||||
|
return ext == "png" || ext == "jpg" || ext == "jpeg" || ext == "webp"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,146 @@
|
|||||||
|
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.local.data.CbzFilter
|
||||||
|
import org.koitharu.kotatsu.local.data.ImageFileFilter
|
||||||
|
import org.koitharu.kotatsu.local.data.LocalManga
|
||||||
|
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||||
|
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||||
|
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.toCamelCase
|
||||||
|
import org.koitharu.kotatsu.utils.AlphanumComparator
|
||||||
|
import org.koitharu.kotatsu.utils.ext.listFilesRecursive
|
||||||
|
import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||||
|
import org.koitharu.kotatsu.utils.ext.toListSorted
|
||||||
|
import java.io.File
|
||||||
|
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 manga = info?.copy2(
|
||||||
|
source = MangaSource.LOCAL,
|
||||||
|
url = mangaUri,
|
||||||
|
coverUrl = fileUri(
|
||||||
|
root,
|
||||||
|
index.getCoverEntry() ?: findFirstImageEntry().orEmpty(),
|
||||||
|
),
|
||||||
|
chapters = info.chapters?.mapIndexed { i, c ->
|
||||||
|
c.copy(url = chapterFiles[i].toUri().toString(), source = MangaSource.LOCAL)
|
||||||
|
},
|
||||||
|
) ?: Manga(
|
||||||
|
id = root.absolutePath.longHashCode(),
|
||||||
|
title = root.name.toHumanReadable(),
|
||||||
|
url = mangaUri,
|
||||||
|
publicUrl = mangaUri,
|
||||||
|
source = MangaSource.LOCAL,
|
||||||
|
coverUrl = findFirstImageEntry().orEmpty(),
|
||||||
|
chapters = chapterFiles.mapIndexed { i, f ->
|
||||||
|
MangaChapter(
|
||||||
|
id = "$i${f.name}".longHashCode(),
|
||||||
|
name = f.nameWithoutExtension.toHumanReadable(),
|
||||||
|
number = i + 1,
|
||||||
|
source = MangaSource.LOCAL,
|
||||||
|
uploadDate = f.lastModified(),
|
||||||
|
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(root, manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.listFilesRecursive(ImageFileFilter())
|
||||||
|
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
|
||||||
|
.map {
|
||||||
|
val pageUri = it.toUri().toString()
|
||||||
|
MangaPage(
|
||||||
|
id = pageUri.longHashCode(),
|
||||||
|
url = pageUri,
|
||||||
|
preview = null,
|
||||||
|
referer = chapter.url,
|
||||||
|
source = MangaSource.LOCAL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} 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,
|
||||||
|
referer = chapter.url,
|
||||||
|
source = MangaSource.LOCAL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
|
||||||
|
|
||||||
|
private fun getChaptersFiles(): List<File> = root.listFilesRecursive(CbzFilter())
|
||||||
|
.toListSorted(compareBy(AlphanumComparator()) { x -> x.name })
|
||||||
|
|
||||||
|
private fun findFirstImageEntry(): String? {
|
||||||
|
val filter = ImageFileFilter()
|
||||||
|
root.listFilesRecursive(filter).firstOrNull()?.let {
|
||||||
|
return it.toUri().toString()
|
||||||
|
}
|
||||||
|
val cbz = root.listFilesRecursive(CbzFilter()).firstOrNull() ?: return null
|
||||||
|
return ZipFile(cbz).use { zip ->
|
||||||
|
val filter = ImageFileFilter()
|
||||||
|
zip.entries().asSequence()
|
||||||
|
.firstOrNull { x -> !x.isDirectory && filter.accept(x) }
|
||||||
|
?.let { entry -> zipUri(cbz, entry.name) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileUri(base: File, name: String): String {
|
||||||
|
return File(base, name).toUri().toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
package org.koitharu.kotatsu.local.data.input
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import org.koitharu.kotatsu.local.data.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 java.io.File
|
||||||
|
|
||||||
|
abstract 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
protected fun zipUri(file: File, entryName: String) = "cbz://${file.path}#$entryName"
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
protected fun Manga.copy2(
|
||||||
|
url: String = this.url,
|
||||||
|
coverUrl: String = this.coverUrl,
|
||||||
|
chapters: List<MangaChapter>? = this.chapters,
|
||||||
|
source: MangaSource = this.source,
|
||||||
|
) = 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 = this.url,
|
||||||
|
source: MangaSource = this.source,
|
||||||
|
) = MangaChapter(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
number = number,
|
||||||
|
url = url,
|
||||||
|
scanlator = scanlator,
|
||||||
|
uploadDate = uploadDate,
|
||||||
|
branch = branch,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,152 @@
|
|||||||
|
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.local.data.LocalManga
|
||||||
|
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||||
|
import org.koitharu.kotatsu.local.data.output.LocalMangaOutput
|
||||||
|
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.toCamelCase
|
||||||
|
import org.koitharu.kotatsu.utils.AlphanumComparator
|
||||||
|
import org.koitharu.kotatsu.utils.ext.longHashCode
|
||||||
|
import org.koitharu.kotatsu.utils.ext.readText
|
||||||
|
import org.koitharu.kotatsu.utils.ext.toListSorted
|
||||||
|
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) {
|
||||||
|
return@use info.copy2(
|
||||||
|
source = MangaSource.LOCAL,
|
||||||
|
url = fileUri,
|
||||||
|
coverUrl = zipUri(
|
||||||
|
root,
|
||||||
|
entryName = index.getCoverEntry()
|
||||||
|
?: findFirstImageEntry(zip.entries())?.name.orEmpty(),
|
||||||
|
),
|
||||||
|
chapters = info.chapters?.map { c ->
|
||||||
|
c.copy(url = fileUri, source = MangaSource.LOCAL)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 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 = MangaSource.LOCAL,
|
||||||
|
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 = i + 1,
|
||||||
|
source = MangaSource.LOCAL,
|
||||||
|
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(root, manga)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
val zip = ZipFile(file)
|
||||||
|
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,
|
||||||
|
referer = chapter.url,
|
||||||
|
source = MangaSource.LOCAL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,114 @@
|
|||||||
|
package org.koitharu.kotatsu.local.data.output
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import org.koitharu.kotatsu.core.zip.ZipOutput
|
||||||
|
import org.koitharu.kotatsu.local.data.MangaIndex
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
|
||||||
|
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||||
|
import org.koitharu.kotatsu.utils.ext.takeIfReadable
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class LocalMangaDirOutput(
|
||||||
|
rootFile: File,
|
||||||
|
manga: Manga,
|
||||||
|
) : LocalMangaOutput(rootFile) {
|
||||||
|
|
||||||
|
private val chaptersOutput = HashMap<MangaChapter, ZipOutput>()
|
||||||
|
private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText())
|
||||||
|
|
||||||
|
init {
|
||||||
|
index.setMangaInfo(manga, append = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun mergeWithExisting() = Unit
|
||||||
|
|
||||||
|
override suspend fun addCover(file: File, ext: String) {
|
||||||
|
val name = buildString {
|
||||||
|
append("cover")
|
||||||
|
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||||
|
append('.')
|
||||||
|
append(ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
file.copyTo(File(rootFile, name))
|
||||||
|
}
|
||||||
|
index.setCoverEntry(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
|
||||||
|
val output = chaptersOutput.getOrPut(chapter) {
|
||||||
|
ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP))
|
||||||
|
}
|
||||||
|
val name = buildString {
|
||||||
|
append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber))
|
||||||
|
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||||
|
append('.')
|
||||||
|
append(ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
output.put(name, file)
|
||||||
|
}
|
||||||
|
index.addChapter(chapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun flushChapter(chapter: MangaChapter) {
|
||||||
|
val output = chaptersOutput.remove(chapter) ?: return
|
||||||
|
output.flushAndFinish()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun finish() {
|
||||||
|
runInterruptible(Dispatchers.IO) {
|
||||||
|
File(rootFile, ENTRY_NAME_INDEX).writeText(index.toString())
|
||||||
|
}
|
||||||
|
for (output in chaptersOutput.values) {
|
||||||
|
output.flushAndFinish()
|
||||||
|
}
|
||||||
|
chaptersOutput.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun cleanup() {
|
||||||
|
for (output in chaptersOutput.values) {
|
||||||
|
output.file.deleteAwait()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
for (output in chaptersOutput.values) {
|
||||||
|
output.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun sortChaptersByName() {
|
||||||
|
index.sortChaptersByName()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteChapter(chapterId: Long) {
|
||||||
|
val chapter = checkNotNull(index.getMangaInfo()?.chapters) {
|
||||||
|
"No chapters found"
|
||||||
|
}.first { it.id == chapterId }
|
||||||
|
val chapterDir = File(rootFile, chapterFileName(chapter))
|
||||||
|
chapterDir.deleteAwait()
|
||||||
|
index.removeChapter(chapterId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ZipOutput.flushAndFinish() = runInterruptible(Dispatchers.IO) {
|
||||||
|
finish()
|
||||||
|
close()
|
||||||
|
val resFile = File(file.absolutePath.removeSuffix(SUFFIX_TMP))
|
||||||
|
file.renameTo(resFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chapterFileName(chapter: MangaChapter): String {
|
||||||
|
return "${chapter.number}_${chapter.name.toFileNameSafe()}".take(18) + ".cbz"
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val FILENAME_PATTERN = "%08d_%03d%03d"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package org.koitharu.kotatsu.local.data.output
|
||||||
|
|
||||||
|
import okio.Closeable
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaChapter
|
||||||
|
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
abstract class LocalMangaOutput(
|
||||||
|
val rootFile: File,
|
||||||
|
) : Closeable {
|
||||||
|
|
||||||
|
abstract suspend fun mergeWithExisting()
|
||||||
|
|
||||||
|
abstract suspend fun addCover(file: File, ext: String)
|
||||||
|
|
||||||
|
abstract suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String)
|
||||||
|
|
||||||
|
abstract suspend fun flushChapter(chapter: MangaChapter)
|
||||||
|
|
||||||
|
abstract suspend fun finish()
|
||||||
|
|
||||||
|
abstract suspend fun cleanup()
|
||||||
|
|
||||||
|
abstract fun sortChaptersByName()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
const val ENTRY_NAME_INDEX = "index.json"
|
||||||
|
const val SUFFIX_TMP = ".tmp"
|
||||||
|
|
||||||
|
fun getOrCreate(root: File, manga: Manga): LocalMangaOutput {
|
||||||
|
return checkNotNull(getImpl(root, manga, onlyIfExists = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(root: File, manga: Manga): LocalMangaOutput? {
|
||||||
|
return getImpl(root, manga, onlyIfExists = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getImpl(root: File, manga: Manga, onlyIfExists: Boolean): LocalMangaOutput? {
|
||||||
|
val name = manga.title.toFileNameSafe()
|
||||||
|
val file = File(root, name)
|
||||||
|
return if (file.exists()) {
|
||||||
|
if (file.isDirectory) {
|
||||||
|
LocalMangaDirOutput(file, manga)
|
||||||
|
} else {
|
||||||
|
LocalMangaZipOutput(file, manga)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (onlyIfExists) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
LocalMangaDirOutput(file, manga)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.local.data
|
package org.koitharu.kotatsu.local.data.util
|
||||||
|
|
||||||
import okhttp3.internal.closeQuietly
|
import okhttp3.internal.closeQuietly
|
||||||
import okio.Closeable
|
import okio.Closeable
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
package org.koitharu.kotatsu.local.data.util
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.FileObserver
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.channels.ProducerScope
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.channels.trySendBlocking
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
fun File.observe() = callbackFlow {
|
||||||
|
val observer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
FlowFileObserverQ(this, this@observe)
|
||||||
|
} else {
|
||||||
|
FlowFileObserver(this, this@observe)
|
||||||
|
}
|
||||||
|
observer.startWatching()
|
||||||
|
awaitClose { observer.stopWatching() }
|
||||||
|
}.flowOn(Dispatchers.IO)
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
|
private class FlowFileObserverQ(
|
||||||
|
private val producerScope: ProducerScope<File>,
|
||||||
|
private val file: File,
|
||||||
|
) : FileObserver(file, CREATE or DELETE or CLOSE_WRITE) {
|
||||||
|
|
||||||
|
override fun onEvent(event: Int, path: String?) {
|
||||||
|
producerScope.trySendBlocking(
|
||||||
|
if (path == null) file else file.resolve(path),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private class FlowFileObserver(
|
||||||
|
private val producerScope: ProducerScope<File>,
|
||||||
|
private val file: File,
|
||||||
|
) : FileObserver(file.absolutePath, CREATE or DELETE or CLOSE_WRITE) {
|
||||||
|
|
||||||
|
override fun onEvent(event: Int, path: String?) {
|
||||||
|
producerScope.trySendBlocking(
|
||||||
|
if (path == null) file else file.resolve(path),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue