Download manga to cbz directly
parent
aaf9c6a0bf
commit
d3e9ce874a
@ -1,51 +0,0 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.utils.MutableZipFile
|
||||
import org.koitharu.kotatsu.utils.ext.format
|
||||
|
||||
class BackupArchive(file: File) : MutableZipFile(file) {
|
||||
|
||||
init {
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun put(entry: BackupEntry) {
|
||||
put(entry.name, entry.data.toString(2))
|
||||
}
|
||||
|
||||
suspend fun getEntry(name: String): BackupEntry {
|
||||
val json = withContext(Dispatchers.Default) {
|
||||
JSONArray(getContent(name))
|
||||
}
|
||||
return BackupEntry(name, json)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val DIR_BACKUPS = "backups"
|
||||
|
||||
suspend fun createNew(context: Context): BackupArchive = runInterruptible(Dispatchers.IO) {
|
||||
val dir = context.run {
|
||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||
}
|
||||
dir.mkdirs()
|
||||
val filename = buildString {
|
||||
append(context.getString(R.string.app_name).lowercase(Locale.ROOT))
|
||||
append('_')
|
||||
append(Date().format("ddMMyyyy"))
|
||||
append(".bak")
|
||||
}
|
||||
BackupArchive(File(dir, filename))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.Closeable
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class BackupZipInput(val file: File) : Closeable {
|
||||
|
||||
private val zipFile = ZipFile(file)
|
||||
|
||||
suspend fun getEntry(name: String): BackupEntry = runInterruptible(Dispatchers.IO) {
|
||||
val entry = zipFile.getEntry(name)
|
||||
val json = zipFile.getInputStream(entry).use {
|
||||
JSONArray(it.bufferedReader().readText())
|
||||
}
|
||||
BackupEntry(name, json)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
zipFile.close()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.Closeable
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.zip.ZipOutput
|
||||
import org.koitharu.kotatsu.utils.ext.format
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import java.util.zip.Deflater
|
||||
|
||||
class BackupZipOutput(val file: File) : Closeable {
|
||||
|
||||
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
|
||||
|
||||
suspend fun put(entry: BackupEntry) {
|
||||
output.put(entry.name, entry.data.toString(2))
|
||||
}
|
||||
|
||||
suspend fun finish() {
|
||||
output.finish()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
output.close()
|
||||
}
|
||||
}
|
||||
|
||||
private const val DIR_BACKUPS = "backups"
|
||||
|
||||
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
||||
val dir = context.run {
|
||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||
}
|
||||
dir.mkdirs()
|
||||
val filename = buildString {
|
||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||
append('_')
|
||||
append(Date().format("ddMMyyyy"))
|
||||
append(".bk.zip")
|
||||
}
|
||||
BackupZipOutput(File(dir, filename))
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
package org.koitharu.kotatsu.core.zip
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.collection.ArraySet
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.Closeable
|
||||
import java.io.File
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
class ZipOutput(
|
||||
val file: File,
|
||||
compressionLevel: Int = Deflater.DEFAULT_COMPRESSION,
|
||||
) : Closeable {
|
||||
|
||||
private val entryNames = ArraySet<String>()
|
||||
private var isClosed = false
|
||||
private val output = ZipOutputStream(file.outputStream()).apply {
|
||||
setLevel(compressionLevel)
|
||||
}
|
||||
|
||||
suspend fun put(name: String, file: File): Unit = runInterruptible(Dispatchers.IO) {
|
||||
entryNames.add(name)
|
||||
output.appendFile(file, name)
|
||||
}
|
||||
|
||||
suspend fun put(name: String, content: String): Unit = runInterruptible(Dispatchers.IO) {
|
||||
entryNames.add(name)
|
||||
output.appendText(content, name)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean {
|
||||
return if (entryNames.add(entry.name)) {
|
||||
val zipEntry = ZipEntry(entry.name)
|
||||
output.putNextEntry(zipEntry)
|
||||
other.getInputStream(entry).use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
output.closeEntry()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun finish() = runInterruptible(Dispatchers.IO) {
|
||||
output.finish()
|
||||
output.flush()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!isClosed) {
|
||||
output.close()
|
||||
isClosed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
package org.koitharu.kotatsu.core.zip
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
@WorkerThread
|
||||
fun ZipOutputStream.appendFile(fileToZip: File, name: String) {
|
||||
if (fileToZip.isDirectory) {
|
||||
if (name.endsWith("/")) {
|
||||
putNextEntry(ZipEntry(name))
|
||||
} else {
|
||||
putNextEntry(ZipEntry("$name/"))
|
||||
}
|
||||
closeEntry()
|
||||
fileToZip.listFiles()?.forEach { childFile ->
|
||||
appendFile(childFile, "$name/${childFile.name}")
|
||||
}
|
||||
} else {
|
||||
FileInputStream(fileToZip).use { fis ->
|
||||
val zipEntry = ZipEntry(name)
|
||||
putNextEntry(zipEntry)
|
||||
fis.copyTo(this)
|
||||
closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun ZipOutputStream.appendText(content: String, name: String) {
|
||||
val zipEntry = ZipEntry(name)
|
||||
putNextEntry(zipEntry)
|
||||
content.byteInputStream().copyTo(this)
|
||||
closeEntry()
|
||||
}
|
||||
@ -1,70 +0,0 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import androidx.annotation.CheckResult
|
||||
import androidx.annotation.WorkerThread
|
||||
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.takeIfReadable
|
||||
import java.io.File
|
||||
|
||||
@WorkerThread
|
||||
class MangaZip(val file: File) {
|
||||
|
||||
private val writableCbz = WritableCbzFile(file)
|
||||
|
||||
private var index = MangaIndex(null)
|
||||
|
||||
suspend fun prepare(manga: Manga) {
|
||||
writableCbz.prepare(overwrite = true)
|
||||
index = MangaIndex(writableCbz[INDEX_ENTRY].takeIfReadable()?.readText())
|
||||
index.setMangaInfo(manga, append = true)
|
||||
}
|
||||
|
||||
suspend fun cleanup() {
|
||||
writableCbz.cleanup()
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
suspend fun compress(): Boolean {
|
||||
writableCbz[INDEX_ENTRY].writeText(index.toString())
|
||||
return writableCbz.flush()
|
||||
}
|
||||
|
||||
suspend fun addCover(file: File, ext: String) {
|
||||
val name = buildString {
|
||||
append(FILENAME_PATTERN.format(0, 0))
|
||||
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||
append('.')
|
||||
append(ext)
|
||||
}
|
||||
}
|
||||
writableCbz.put(name, file)
|
||||
index.setCoverEntry(name)
|
||||
}
|
||||
|
||||
suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
|
||||
val name = buildString {
|
||||
append(FILENAME_PATTERN.format(chapter.number, pageNumber))
|
||||
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||
append('.')
|
||||
append(ext)
|
||||
}
|
||||
}
|
||||
writableCbz.put(name, file)
|
||||
index.addChapter(chapter)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val FILENAME_PATTERN = "%03d%03d"
|
||||
|
||||
const val INDEX_ENTRY = "index.json"
|
||||
|
||||
fun findInDir(root: File, manga: Manga): MangaZip {
|
||||
val name = manga.title.toFileNameSafe() + ".cbz"
|
||||
val file = File(root, name)
|
||||
return MangaZip(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,99 +0,0 @@
|
||||
package org.koitharu.kotatsu.local.data
|
||||
|
||||
import androidx.annotation.CheckResult
|
||||
import kotlinx.coroutines.*
|
||||
import org.koitharu.kotatsu.utils.ext.deleteAwait
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
class WritableCbzFile(private val file: File) {
|
||||
|
||||
private val dir = File(file.parentFile, file.nameWithoutExtension)
|
||||
|
||||
suspend fun prepare(overwrite: Boolean) = withContext(Dispatchers.IO) {
|
||||
if (!dir.list().isNullOrEmpty()) {
|
||||
if (overwrite) {
|
||||
dir.deleteRecursively()
|
||||
} else {
|
||||
throw IllegalStateException("Dir ${dir.name} is not empty")
|
||||
}
|
||||
}
|
||||
if (!dir.exists()) {
|
||||
dir.mkdir()
|
||||
}
|
||||
if (!file.exists()) {
|
||||
return@withContext
|
||||
}
|
||||
ZipInputStream(FileInputStream(file)).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null && currentCoroutineContext().isActive) {
|
||||
val target = File(dir.path + File.separator + entry.name)
|
||||
runInterruptible {
|
||||
target.parentFile?.mkdirs()
|
||||
target.outputStream().use { out ->
|
||||
zip.copyTo(out)
|
||||
}
|
||||
}
|
||||
zip.closeEntry()
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun cleanup() = withContext(Dispatchers.IO) {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
suspend fun flush() = withContext(Dispatchers.IO) {
|
||||
val tempFile = File(file.path + ".tmp")
|
||||
if (tempFile.exists()) {
|
||||
tempFile.deleteAwait()
|
||||
}
|
||||
try {
|
||||
runInterruptible {
|
||||
ZipOutputStream(FileOutputStream(tempFile)).use { zip ->
|
||||
dir.listFiles()?.forEach {
|
||||
zipFile(it, it.name, zip)
|
||||
}
|
||||
zip.flush()
|
||||
}
|
||||
}
|
||||
tempFile.renameTo(file)
|
||||
} finally {
|
||||
if (tempFile.exists()) {
|
||||
tempFile.deleteAwait()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
operator fun get(name: String) = File(dir, name)
|
||||
|
||||
suspend fun put(name: String, file: File) = runInterruptible(Dispatchers.IO) {
|
||||
file.copyTo(this[name], overwrite = true)
|
||||
}
|
||||
|
||||
private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) {
|
||||
if (fileToZip.isDirectory) {
|
||||
if (fileName.endsWith("/")) {
|
||||
zipOut.putNextEntry(ZipEntry(fileName))
|
||||
} else {
|
||||
zipOut.putNextEntry(ZipEntry("$fileName/"))
|
||||
}
|
||||
zipOut.closeEntry()
|
||||
fileToZip.listFiles()?.forEach { childFile ->
|
||||
zipFile(childFile, "$fileName/${childFile.name}", zipOut)
|
||||
}
|
||||
} else {
|
||||
FileInputStream(fileToZip).use { fis ->
|
||||
val zipEntry = ZipEntry(fileName)
|
||||
zipOut.putNextEntry(zipEntry)
|
||||
fis.copyTo(zipOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
package org.koitharu.kotatsu.local.domain
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import okio.Closeable
|
||||
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 java.io.File
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
class CbzMangaOutput(
|
||||
val file: File,
|
||||
manga: Manga,
|
||||
) : Closeable {
|
||||
|
||||
private val output = ZipOutput(File(file.path + ".tmp"))
|
||||
private val index = MangaIndex(null)
|
||||
|
||||
init {
|
||||
index.setMangaInfo(manga, false)
|
||||
}
|
||||
|
||||
suspend fun mergeWithExisting() {
|
||||
if (file.exists()) {
|
||||
runInterruptible(Dispatchers.IO) {
|
||||
mergeWith(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun addCover(file: File, ext: String) {
|
||||
val name = buildString {
|
||||
append(FILENAME_PATTERN.format(0, 0))
|
||||
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||
append('.')
|
||||
append(ext)
|
||||
}
|
||||
}
|
||||
output.put(name, file)
|
||||
index.setCoverEntry(name)
|
||||
}
|
||||
|
||||
suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
|
||||
val name = buildString {
|
||||
append(FILENAME_PATTERN.format(chapter.number, pageNumber))
|
||||
if (ext.isNotEmpty() && ext.length <= 4) {
|
||||
append('.')
|
||||
append(ext)
|
||||
}
|
||||
}
|
||||
output.put(name, file)
|
||||
index.addChapter(chapter)
|
||||
}
|
||||
|
||||
suspend fun finalize() {
|
||||
output.put(ENTRY_NAME_INDEX, index.toString())
|
||||
output.finish()
|
||||
output.close()
|
||||
file.deleteAwait()
|
||||
output.file.renameTo(file)
|
||||
}
|
||||
|
||||
suspend fun cleanup() {
|
||||
output.file.deleteAwait()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
output.close()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun mergeWith(other: File) {
|
||||
var otherIndex: MangaIndex? = null
|
||||
ZipFile(other).use { zip ->
|
||||
for (entry in zip.entries()) {
|
||||
if (entry.name == ENTRY_NAME_INDEX) {
|
||||
otherIndex = MangaIndex(
|
||||
zip.getInputStream(entry).use {
|
||||
it.reader().readText()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
output.copyEntryFrom(zip, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
otherIndex?.getMangaInfo()?.chapters?.let { chapters ->
|
||||
for (chapter in chapters) {
|
||||
index.addChapter(chapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val FILENAME_PATTERN = "%03d%03d"
|
||||
|
||||
const val ENTRY_NAME_INDEX = "index.json"
|
||||
|
||||
fun createNew(root: File, manga: Manga): CbzMangaOutput {
|
||||
val name = manga.title.toFileNameSafe() + ".cbz"
|
||||
val file = File(root, name)
|
||||
return CbzMangaOutput(file, manga)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
package org.koitharu.kotatsu.utils
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
open class MutableZipFile(val file: File) {
|
||||
|
||||
protected val dir = File(file.parentFile, file.nameWithoutExtension)
|
||||
|
||||
suspend fun unpack(): Unit = runInterruptible(Dispatchers.IO) {
|
||||
check(dir.list().isNullOrEmpty()) {
|
||||
"Dir ${dir.name} is not empty"
|
||||
}
|
||||
if (!dir.exists()) {
|
||||
dir.mkdir()
|
||||
}
|
||||
if (!file.exists()) {
|
||||
return@runInterruptible
|
||||
}
|
||||
ZipInputStream(FileInputStream(file)).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
val target = File(dir.path + File.separator + entry.name)
|
||||
target.parentFile?.mkdirs()
|
||||
target.outputStream().use { out ->
|
||||
zip.copyTo(out)
|
||||
}
|
||||
zip.closeEntry()
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun cleanup() = withContext(Dispatchers.IO) {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
|
||||
suspend fun flush(): Boolean = runInterruptible(Dispatchers.IO) {
|
||||
val tempFile = File(file.path + ".tmp")
|
||||
if (tempFile.exists()) {
|
||||
tempFile.delete()
|
||||
}
|
||||
try {
|
||||
ZipOutputStream(FileOutputStream(tempFile)).use { zip ->
|
||||
dir.listFiles()?.forEach {
|
||||
zipFile(it, it.name, zip)
|
||||
}
|
||||
zip.flush()
|
||||
}
|
||||
tempFile.renameTo(file)
|
||||
} finally {
|
||||
if (tempFile.exists()) {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
operator fun get(name: String) = File(dir, name)
|
||||
|
||||
suspend fun put(name: String, file: File): Unit = withContext(Dispatchers.IO) {
|
||||
file.copyTo(this@MutableZipFile[name], overwrite = true)
|
||||
}
|
||||
|
||||
suspend fun put(name: String, data: String): Unit = withContext(Dispatchers.IO) {
|
||||
this@MutableZipFile[name].writeText(data)
|
||||
}
|
||||
|
||||
suspend fun getContent(name: String): String = withContext(Dispatchers.IO) {
|
||||
get(name).readText()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@WorkerThread
|
||||
private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) {
|
||||
if (fileToZip.isDirectory) {
|
||||
if (fileName.endsWith("/")) {
|
||||
zipOut.putNextEntry(ZipEntry(fileName))
|
||||
} else {
|
||||
zipOut.putNextEntry(ZipEntry("$fileName/"))
|
||||
}
|
||||
zipOut.closeEntry()
|
||||
fileToZip.listFiles()?.forEach { childFile ->
|
||||
zipFile(childFile, "$fileName/${childFile.name}", zipOut)
|
||||
}
|
||||
} else {
|
||||
FileInputStream(fileToZip).use { fis ->
|
||||
val zipEntry = ZipEntry(fileName)
|
||||
zipOut.putNextEntry(zipEntry)
|
||||
fis.copyTo(zipOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue