Compare commits

..

55 Commits

Author SHA1 Message Date
Aria Moradi 819ceba17d bump version
CI Publish / Validate Gradle Wrapper (push) Successful in 12s
CI Publish / Build artifacts and release (push) Failing after 16s
2021-09-28 00:52:49 +03:30
Aria Moradi 0aa0d62e03 update changelog file and it's template 2021-09-28 00:51:05 +03:30
Aria Moradi b3e2a35880 update WebUI 2021-09-28 00:50:33 +03:30
Aria Moradi 15ec20c65d fix sorting 2021-09-27 20:27:40 +03:30
Aria Moradi d4d6d7e12f add recentChapters endpoint 2021-09-27 18:27:05 +03:30
Aria Moradi 2e7a4f1421 remove no longer relevant comment 2021-09-27 14:44:48 +03:30
Aria Moradi ab8a52faf3 rename ChapterTable.chapterIndex to ChapterTable.sourceOrder 2021-09-27 14:36:06 +03:30
Aria Moradi bd465559fb Update README.md 2021-09-26 23:48:29 +03:30
Aria Moradi 13ec45a95c aftermath of adding kotlinter to all modules 2021-09-25 04:34:02 +03:30
Mitchell Syer 13b034875b Workaround StdLib issue and add KtLint to all modules (#206)
* Workaround buildconfig kotlin stdlib issue

* Add KtLint to all modules
2021-09-25 04:31:03 +03:30
Aria Moradi bb701fb088 fix macOS-arm64 java path 2021-09-24 14:06:19 +03:30
Aria Moradi b367414865 changes 2021-09-24 13:56:26 +03:30
Aria Moradi 4b00eec608 update CHANGELOG 2021-09-19 18:01:13 +04:30
Aria Moradi 5e11b51152 update CHANGELOG 2021-09-19 17:59:37 +04:30
Aria Moradi 9fb43b996e CHANGELOG update 2021-09-19 17:39:28 +04:30
Aria Moradi bc2072e81f bump version
CI Publish / Validate Gradle Wrapper (push) Successful in 13s
CI Publish / Build artifacts and release (push) Failing after 15s
2021-09-19 17:36:46 +04:30
Aria Moradi f36bc3f643 update WebUI 2021-09-19 17:34:18 +04:30
Aria Moradi f7901ad843 fix windows paths 2021-09-19 16:43:16 +04:30
Aria Moradi 3771030ed6 closes #202 2021-09-19 14:24:13 +04:30
Aria Moradi 57197e58b5 fix Task path 2021-09-19 14:14:42 +04:30
Aria Moradi ac601399ac update WebUI 2021-09-19 14:14:21 +04:30
Aria Moradi 6a0e221153 fix compile 2021-09-19 01:01:20 +04:30
Aria Moradi 6a949fc851 Minor cleanup 2021-09-19 00:59:04 +04:30
Aria Moradi f1a077dc2f update CHANGELOG 2021-09-18 22:09:34 +04:30
Mitchell Syer f20962b02b Gradle Updates (#199)
* Cleanup and update gradle, update dependencies

* Duplicate Jsoup
2021-09-18 22:07:19 +04:30
Mitchell Syer 77e057f244 Update BytecodeEditor to use Java NIO Paths (#200) 2021-09-18 21:57:15 +04:30
Aria Moradi 2c5114c770 bump version
CI Publish / Validate Gradle Wrapper (push) Successful in 13s
CI Publish / Build artifacts and release (push) Failing after 16s
2021-09-18 21:07:38 +04:30
Aria Moradi a30895a199 update WebUI 2021-09-18 21:03:01 +04:30
Aria Moradi 6d46d4b3da closes #131 2021-09-18 20:30:56 +04:30
Aria Moradi 006efbbb77 add ChapterRecognition from tachiyomi, closes #10 2021-09-18 19:40:44 +04:30
Aria Moradi 52334087ad add support for Archive chapters to Local source 2021-09-18 19:14:06 +04:30
Aria Moradi ea8fb2c70a add comment 2021-09-18 16:11:25 +04:30
Aria Moradi 531d148718 don't save Local source chapters into disk again! 2021-09-18 14:53:59 +04:30
Aria Moradi d83ddea323 better URLs 2021-09-18 14:49:38 +04:30
Aria Moradi b5dea34090 update WebUI 2021-09-18 02:39:15 +04:30
Aria Moradi 7d8e3202b5 lint 2021-09-18 02:28:52 +04:30
Aria Moradi d956e0af4b add Local Source (#196) 2021-09-18 02:20:30 +04:30
Aria Moradi 9ad70990b5 update WebUI 2021-09-18 02:19:43 +04:30
Aria Moradi 82d711f077 fix typo 2021-09-18 02:19:38 +04:30
Aria Moradi 5fc28ef711 handle when title is changing properly 2021-09-18 02:10:35 +04:30
Aria Moradi 1f3dc682e2 migrate to kotlinx.json 2021-09-18 01:43:10 +04:30
Aria Moradi cce7768246 clean up imports 2021-09-18 01:03:06 +04:30
Aria Moradi 01172b0664 better exception messages 2021-09-18 01:00:42 +04:30
Aria Moradi 1ca11fdd34 add Local Source 2021-09-18 00:47:50 +04:30
Aria Moradi 52a064ae45 lint 2021-09-17 16:32:14 +04:30
Aria Moradi 6c62ddf927 depricate zero based chapters 2021-09-17 16:31:47 +04:30
Aria Moradi 47e04c08d0 fix lint complain 2021-09-17 15:17:18 +04:30
Aria Moradi d1601874b4 [SKIP CI] update CHANGELOG.md 2021-09-17 01:31:13 +04:30
Aria Moradi 6bc19af041 add ability to delete downloaded chapters 2021-09-17 01:29:42 +04:30
Aria Moradi 4e72a3886f actaully make sure the chapter exists 2021-09-17 00:38:15 +04:30
Aria Moradi 8e6b219eea update CHANGELOG.md 2021-09-14 04:18:19 +04:30
Aria Moradi bd638251e4 add BasicAuth support 2021-09-14 04:17:01 +04:30
Aria Moradi 0173d5e4b3 migrate to Javalin 4 2021-09-14 03:23:00 +04:30
Aria Moradi 4f364e134b remove expand char limit on MangaTable columns 2021-09-13 20:57:41 +04:30
Aria Moradi 94d2519717 add CHANGELOG for the next release 2021-09-13 20:14:02 +04:30
90 changed files with 2031 additions and 961 deletions
@@ -14,7 +14,7 @@ const val CONFIG_PREFIX = "suwayomi.tachidesk.config"
val ApplicationRootDir: String
get(): String {
return System.getProperty(
"$CONFIG_PREFIX.server.rootDir",
AppDirsFactory.getInstance().getUserDataDir("Tachidesk", null, null)
"$CONFIG_PREFIX.server.rootDir",
AppDirsFactory.getInstance().getUserDataDir("Tachidesk", null, null)
)
}
}
@@ -6,7 +6,7 @@ import org.kodein.di.singleton
class ConfigKodeinModule {
fun create() = DI.Module("ConfigManager") {
//Config module
// Config module
bind<ConfigManager>() with singleton { GlobalConfigManager }
}
}
}
@@ -21,7 +21,7 @@ open class ConfigManager {
private val generatedModules = mutableMapOf<Class<out ConfigModule>, ConfigModule>()
val config by lazy { loadConfigs() }
//Public read-only view of modules
// Public read-only view of modules
val loadedModules: Map<Class<out ConfigModule>, ConfigModule>
get() = generatedModules
@@ -42,29 +42,28 @@ open class ConfigManager {
* Load configs
*/
fun loadConfigs(): Config {
//Load reference configs
// Load reference configs
val compatConfig = ConfigFactory.parseResources("compat-reference.conf")
val serverConfig = ConfigFactory.parseResources("server-reference.conf")
val baseConfig =
ConfigFactory.parseMap(
mapOf(
"androidcompat.rootDir" to "$ApplicationRootDir/android-compat" // override AndroidCompat's rootDir
)
ConfigFactory.parseMap(
mapOf(
"androidcompat.rootDir" to "$ApplicationRootDir/android-compat" // override AndroidCompat's rootDir
)
)
//Load user config
// Load user config
val userConfig =
File(ApplicationRootDir, "server.conf").let {
ConfigFactory.parseFile(it)
}
File(ApplicationRootDir, "server.conf").let {
ConfigFactory.parseFile(it)
}
val config = ConfigFactory.empty()
.withFallback(baseConfig)
.withFallback(userConfig)
.withFallback(compatConfig)
.withFallback(serverConfig)
.resolve()
.withFallback(baseConfig)
.withFallback(userConfig)
.withFallback(compatConfig)
.withFallback(serverConfig)
.resolve()
// set log level early
if (debugLogsEnabled(config)) {
@@ -20,7 +20,7 @@ abstract class ConfigModule(config: Config)
/**
* Abstract jvm-commandline-argument-overridable config module.
*/
abstract class SystemPropertyOverridableConfigModule(config: Config, moduleName: String): ConfigModule(config) {
abstract class SystemPropertyOverridableConfigModule(config: Config, moduleName: String) : ConfigModule(config) {
val overridableConfig = SystemPropertyOverrideDelegate(config, moduleName)
}
@@ -34,7 +34,7 @@ class SystemPropertyOverrideDelegate(val config: Config, val moduleName: String)
configValue.toString()
)
return when(T::class.simpleName) {
return when (T::class.simpleName) {
"Int" -> combined.toInt()
"Boolean" -> combined.toBoolean()
// add more types as needed
@@ -16,5 +16,5 @@ fun setLogLevel(level: Level) {
(KotlinLogging.logger(Logger.ROOT_LOGGER_NAME).underlyingLogger as ch.qos.logback.classic.Logger).level = level
}
fun debugLogsEnabled(config: Config)
= System.getProperty("suwayomi.tachidesk.config.server.debugLogsEnabled", config.getString("server.debugLogsEnabled")).toBoolean()
fun debugLogsEnabled(config: Config) =
System.getProperty("suwayomi.tachidesk.config.server.debugLogsEnabled", config.getString("server.debugLogsEnabled")).toBoolean()
@@ -3,4 +3,4 @@ package xyz.nulldev.ts.config.util
import com.typesafe.config.Config
operator fun Config.get(key: String) = getString(key)
?: throw IllegalStateException("Could not find value for config entry: $key!")
?: throw IllegalStateException("Could not find value for config entry: $key!")
+4 -44
View File
@@ -1,68 +1,28 @@
plugins {
application
kotlin("plugin.serialization")
}
repositories {
mavenCentral()
maven {
url = uri("https://jitpack.io")
}
maven {
url = uri("https://maven.google.com")
}
}
dependencies {
// Android stub library
implementation(fileTree("lib/"))
// JSON
compileOnly("com.google.code.gson:gson:2.8.6")
// XML
compileOnly(group= "xmlpull", name= "xmlpull", version= "1.1.3.1")
compileOnly("xmlpull:xmlpull:1.1.3.4a")
// Config API
implementation(project(":AndroidCompat:Config"))
// APK sig verifier
compileOnly("com.android.tools.build:apksig:4.2.0-alpha13")
compileOnly("com.android.tools.build:apksig:7.1.0-alpha12")
// AndroidX annotations
compileOnly("androidx.annotation:annotation:1.2.0-alpha01")
compileOnly("androidx.annotation:annotation:1.2.0")
// substitute for duktape-android
implementation("org.mozilla:rhino-runtime:1.7.13") // slimmer version of 'org.mozilla:rhino'
implementation("org.mozilla:rhino-engine:1.7.13") // provides the same interface as 'javax.script' a.k.a Nashorn
// Kotlin wrapper around Java Preferences, makes certain things easier
val multiplatformSettingsVersion = "0.7.7"
val multiplatformSettingsVersion = "0.8"
implementation("com.russhwolf:multiplatform-settings-jvm:$multiplatformSettingsVersion")
implementation("com.russhwolf:multiplatform-settings-serialization-jvm:$multiplatformSettingsVersion")
// Android version of SimpleDateFormat
implementation("com.ibm.icu:icu4j:69.1")
}
tasks {
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
kotlinOptions.freeCompilerArgs = listOf("-Xopt-in=kotlin.RequiresOptIn")
}
}
//def fatJarTask = tasks.getByPath(':AndroidCompat:JVMPatch:fatJar')
//
//// Copy JVM core patches
//task copyJVMPatches(type: Copy) {
// from fatJarTask.outputs.files
// into 'src/main/resources/patches'
//}
//
//compileOnly(Java.dependsOn gradle.includedBuild('dex2jar').task(':dex-translator:assemble')
//compileOnly(Java.dependsOn copyJVMPatches
//copyJVMPatches.dependsOn fatJarTask
//
@@ -9,8 +9,10 @@ import android.content.Context
class PreferenceManager {
companion object {
@JvmStatic
fun getDefaultSharedPreferences(context: Context)
= context.getSharedPreferences(context.applicationInfo.packageName,
Context.MODE_PRIVATE)!!
fun getDefaultSharedPreferences(context: Context) =
context.getSharedPreferences(
context.applicationInfo.packageName,
Context.MODE_PRIVATE
)!!
}
}
@@ -13,7 +13,10 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
public class TwoStatePreference extends Preference {
// Note: remove @JsonIgnore and implement methods if any extension ever uses these methods or the variables behind them
public TwoStatePreference(Context context) { super(context); }
public TwoStatePreference(Context context) {
super(context);
setDefaultValue(false);
}
@JsonIgnore
public boolean isChecked() { throw new RuntimeException("Stub!"); }
@@ -5,4 +5,4 @@ import android.arch.persistence.db.framework.FrameworkSQLiteOpenHelperFactory
class RequerySQLiteOpenHelperFactory {
fun create(configuration: SupportSQLiteOpenHelper.Configuration) = FrameworkSQLiteOpenHelperFactory().create(configuration)
}
}
@@ -14,4 +14,4 @@ class AndroidCompat {
application.attach(context)
application.onCreate()
}
}
}
@@ -14,7 +14,7 @@ class AndroidCompatInitializer {
fun init() {
DI.global.addImport(AndroidCompatModule().create())
//Register config modules
// Register config modules
GlobalConfigManager.registerModules(
FilesConfigModule.register(GlobalConfigManager.config),
ApplicationInfoConfigModule.register(GlobalConfigManager.config),
@@ -29,7 +29,7 @@ class AndroidCompatModule {
bind<PackageController>() with singleton { PackageController() }
//Context
// Context
bind<CustomContext>() with singleton { CustomContext() }
bind<Context>() with singleton {
val context: Context by DI.global.instance<CustomContext>()
@@ -13,7 +13,7 @@ class ApplicationInfoConfigModule(config: Config) : ConfigModule(config) {
val debug: Boolean by config
companion object {
fun register(config: Config)
= ApplicationInfoConfigModule(config.getConfig("android.app"))
fun register(config: Config) =
ApplicationInfoConfigModule(config.getConfig("android.app"))
}
}
@@ -9,26 +9,26 @@ import xyz.nulldev.ts.config.ConfigModule
*/
class FilesConfigModule(config: Config) : ConfigModule(config) {
val dataDir:String by config
val filesDir:String by config
val noBackupFilesDir:String by config
val dataDir: String by config
val filesDir: String by config
val noBackupFilesDir: String by config
val externalFilesDirs: MutableList<String> by config
val obbDirs: MutableList<String> by config
val cacheDir:String by config
val codeCacheDir:String by config
val cacheDir: String by config
val codeCacheDir: String by config
val externalCacheDirs: MutableList<String> by config
val externalMediaDirs: MutableList<String> by config
val rootDir:String by config
val externalStorageDir:String by config
val downloadCacheDir:String by config
val databasesDir:String by config
val rootDir: String by config
val externalStorageDir: String by config
val downloadCacheDir: String by config
val databasesDir: String by config
val prefsDir:String by config
val prefsDir: String by config
val packageDir:String by config
val packageDir: String by config
companion object {
fun register(config: Config)
= FilesConfigModule(config.getConfig("android.files"))
fun register(config: Config) =
FilesConfigModule(config.getConfig("android.files"))
}
}
}
@@ -1,8 +1,8 @@
package xyz.nulldev.androidcompat.config
import com.typesafe.config.Config
import xyz.nulldev.ts.config.ConfigModule
import io.github.config4k.getValue
import xyz.nulldev.ts.config.ConfigModule
class SystemConfigModule(val config: Config) : ConfigModule(config) {
val isDebuggable: Boolean by config
@@ -16,7 +16,7 @@ class SystemConfigModule(val config: Config) : ConfigModule(config) {
fun hasProperty(property: String) = config.hasPath("$propertyPrefix$property")
companion object {
fun register(config: Config)
= SystemConfigModule(config.getConfig("android.system"))
fun register(config: Config) =
SystemConfigModule(config.getConfig("android.system"))
}
}
@@ -29,7 +29,7 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
val parentMetadata = parent.metaData
val columnCount = parentMetadata.columnCount
val columnLabels = (1 .. columnCount).map {
val columnLabels = (1..columnCount).map {
parentMetadata.getColumnLabel(it)
}.toTypedArray()
@@ -41,10 +41,10 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
// How can we optimize this?
// We need to fill the cache as the set is loaded
//Fill cache
while(parent.next()) {
// Fill cache
while (parent.next()) {
cachedContent += ResultSetEntry().apply {
for(i in 1 .. columnCount)
for (i in 1..columnCount)
data += parent.getObject(i)
}
resultSetLength++
@@ -60,8 +60,8 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
private fun internalMove(row: Int) {
if(cursor < 0) cursor = 0
else if(cursor > resultSetLength + 1) cursor = resultSetLength + 1
if (cursor < 0) cursor = 0
else if (cursor > resultSetLength + 1) cursor = resultSetLength + 1
else cursor = row
}
@@ -75,10 +75,10 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
return obj(cachedFindColumn(column))
}
private fun cachedFindColumn(column: String?)
= columnCache.getOrPut(column!!, {
findColumn(column)
})
private fun cachedFindColumn(column: String?) =
columnCache.getOrPut(column!!, {
findColumn(column)
})
override fun getNClob(columnIndex: Int): NClob {
return obj(columnIndex) as NClob
@@ -157,27 +157,27 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getDate(columnIndex: Int): Date {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getDate(columnLabel: String?): Date {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getDate(columnIndex: Int, cal: Calendar?): Date {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getDate(columnLabel: String?, cal: Calendar?): Date {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun beforeFirst() {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -202,12 +202,12 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getBigDecimal(columnIndex: Int, scale: Int): BigDecimal {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getBigDecimal(columnLabel: String?, scale: Int): BigDecimal {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -236,22 +236,22 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getTime(columnIndex: Int): Time {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getTime(columnLabel: String?): Time {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getTime(columnIndex: Int, cal: Calendar?): Time {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getTime(columnLabel: String?, cal: Calendar?): Time {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -272,28 +272,28 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun absolute(row: Int): Boolean {
if(row > 0) {
if (row > 0) {
internalMove(row)
} else {
last()
for(i in 1 .. row)
for (i in 1..row)
previous()
}
return cursorValid()
}
override fun getSQLXML(columnIndex: Int): SQLXML? {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getSQLXML(columnLabel: String?): SQLXML? {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun <T : Any?> unwrap(iface: Class<T>?): T {
if(thisIsWrapperFor(iface))
if (thisIsWrapperFor(iface))
return this as T
else
return parent.unwrap(iface)
@@ -426,12 +426,12 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getBlob(columnIndex: Int): Blob {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getBlob(columnLabel: String?): Blob {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -500,12 +500,12 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getObject(columnIndex: Int, map: MutableMap<String, Class<*>>?): Any {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getObject(columnLabel: String?, map: MutableMap<String, Class<*>>?): Any {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -531,9 +531,9 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
private fun castToLong(obj: Any?): Long {
if(obj == null) return 0
else if(obj is Long) return obj
else if(obj is Number) return obj.toLong()
if (obj == null) return 0
else if (obj is Long) return obj
else if (obj is Number) return obj.toLong()
else throw IllegalStateException("Object is not a long!")
}
@@ -546,12 +546,12 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getClob(columnIndex: Int): Clob {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getClob(columnLabel: String?): Clob {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -604,12 +604,12 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getArray(columnIndex: Int): Array {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getArray(columnLabel: String?): Array {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -688,32 +688,32 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getTimestamp(columnIndex: Int): Timestamp {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getTimestamp(columnLabel: String?): Timestamp {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getTimestamp(columnIndex: Int, cal: Calendar?): Timestamp {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getTimestamp(columnLabel: String?, cal: Calendar?): Timestamp {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getRef(columnIndex: Int): Ref {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getRef(columnLabel: String?): Ref {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -792,12 +792,12 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
}
override fun getRowId(columnIndex: Int): RowId {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
override fun getRowId(columnLabel: String?): RowId {
//TODO Maybe?
// TODO Maybe?
notImplemented()
}
@@ -848,4 +848,4 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
class ResultSetEntry {
val data = mutableListOf<Any?>()
}
}
}
@@ -174,4 +174,4 @@ class JavaSharedPreferences(key: String) : SharedPreferences {
javaPreferences.removeNode()
return true
}
}
}
@@ -14,8 +14,6 @@ import java.io.File
import javax.imageio.ImageIO
import javax.xml.parsers.DocumentBuilderFactory
data class InstalledPackage(val root: File) {
val apk = File(root, "package.apk")
val jar = File(root, "translated.jar")
@@ -40,20 +38,24 @@ data class InstalledPackage(val root: File) {
}?.filter {
it.tagName == "meta-data"
}?.map {
putString(it.attributes.getNamedItem("android:name").nodeValue,
it.attributes.getNamedItem("android:value").nodeValue)
putString(
it.attributes.getNamedItem("android:name").nodeValue,
it.attributes.getNamedItem("android:value").nodeValue
)
}
}
it.signatures = (parsed.apkSingers.flatMap { it.certificateMetas }
/*+ parsed.apkV2Singers.flatMap { it.certificateMetas }*/) // Blocked by: https://github.com/hsiafan/apk-parser/issues/72
.map { Signature(it.data) }.toTypedArray()
it.signatures = (
parsed.apkSingers.flatMap { it.certificateMetas }
/*+ parsed.apkV2Singers.flatMap { it.certificateMetas }*/
) // Blocked by: https://github.com/hsiafan/apk-parser/issues/72
.map { Signature(it.data) }.toTypedArray()
}
fun verify(): Boolean {
val res = ApkVerifier.Builder(apk)
.build()
.verify()
.build()
.verify()
return res.isVerified
}
@@ -69,7 +71,7 @@ data class InstalledPackage(val root: File) {
}.sortedByDescending { it.width * it.height }.firstOrNull() ?: return
ImageIO.write(read, "png", icon)
} catch(e: Exception) {
} catch (e: Exception) {
icon.delete()
}
}
@@ -77,7 +79,7 @@ data class InstalledPackage(val root: File) {
fun writeJar() {
try {
Dex2jar.from(apk).to(jar.toPath())
} catch(e: Exception) {
} catch (e: Exception) {
jar.delete()
}
}
@@ -92,4 +94,4 @@ data class InstalledPackage(val root: File) {
return out
}
}
}
}
@@ -48,7 +48,7 @@ class PackageController {
if (!installed.jar.exists()) {
throw IllegalStateException("Failed to translate APK dex!")
}
} catch(t: Throwable) {
} catch (t: Throwable) {
root.deleteRecursively()
throw t
}
@@ -63,7 +63,7 @@ class PackageController {
}
fun deletePackage(pack: InstalledPackage) {
if(!pack.root.exists()) error("Package was never installed!")
if (!pack.root.exists()) error("Package was never installed!")
val packageName = pack.info.packageName
pack.root.deleteRecursively()
@@ -74,7 +74,7 @@ class PackageController {
fun findPackage(packageName: String): InstalledPackage? {
val file = File(androidFiles.packagesDir, packageName)
return if(file.exists())
return if (file.exists())
InstalledPackage(file)
else
null
@@ -84,4 +84,4 @@ class PackageController {
val pkgName = ApkParsers.getMetaInfo(apkFile).packageName
return findPackage(pkgName)?.jar
}
}
}
@@ -24,4 +24,4 @@ fun ApkMeta.toPackageInfo(apk: File): PackageInfo {
sourceDir = apk.absolutePath
}
}
}
}
@@ -24,4 +24,4 @@ interface Resource {
fun getType(): Class<out Resource>
fun getValue(): Any?
}
}
@@ -27,10 +27,10 @@ class ServiceSupport {
runningServices[name] = service
//Setup service
// Setup service
thread {
callOnCreate(service)
//TODO Handle more complex cases
// TODO Handle more complex cases
service.onStartCommand(intent, 0, 0)
}
}
@@ -43,7 +43,7 @@ class ServiceSupport {
fun stopService(name: String) {
logger.debug { "Stopping service: $name" }
val service = runningServices.remove(name)
if(service == null) {
if (service == null) {
logger.warn { "An attempt was made to stop a service that is not running: $name" }
} else {
thread {
@@ -63,6 +63,6 @@ class ServiceSupport {
fun serviceInstanceFromClass(className: String): Service {
val clazzObj = Class.forName(className)
return clazzObj.getDeclaredConstructor().newInstance() as? Service
?: throw IllegalArgumentException("$className is not a Service!")
?: throw IllegalArgumentException("$className is not a Service!")
}
}
@@ -27,7 +27,7 @@ object KodeinGlobalHelper {
@JvmStatic
@Suppress("UNCHECKED_CAST")
fun <T : Any> instance(type: Class<T>, kodein: DI? = null): T {
return when(type) {
return when (type) {
AndroidFiles::class.java -> {
val instance: AndroidFiles by (kodein ?: kodein()).instance()
instance as T
@@ -64,5 +64,4 @@ object KodeinGlobalHelper {
fun <T : Any> instance(type: Class<T>): T {
return instance(type, null)
}
}
+2 -20
View File
@@ -2,27 +2,9 @@
## TL;DR
<!-- TODO: fill before release -->
## Tachidesk-Server
### Public API
#### Non-breaking changes
- N/A
#### Breaking changes
- N/A
#### Bug fixes
- N/A
### Private API
## Tachidesk-Server Changelog
- N/A
## Tachidesk-WebUI
#### Visible changes
- N/A
#### Bug fixes
- N/A
#### Internal changes
## Tachidesk-WebUI Changelog
- N/A
+103
View File
@@ -1,3 +1,106 @@
# Server: v0.5.3 + WebUI: r809
## TL;DR
<!-- TODO: fill before release -->
## Tachidesk-Server Changelog
- (r956) fix macOS-arm64 bundle launchers not working
- (r957) Workaround StdLib issue and add KtLint to all modules ([#206](https://github.com/Suwayomi/Tachidesk-Server/pull/206) by @Syer10)
- (r960-r963) Add recently updated chapters(Updates) endpoint
## Tachidesk-WebUI Changelog
- (r808) fix chapter list not calling onlineFetch=true
- (r809) add support for Updates
# Server: v0.5.2 + WebUI: r807
## TL;DR
- Fixed Local source not working on Windows
- Fixed Chapter numbers being shown incorrectly
## Tachidesk-Server
### Public API
#### Non-breaking changes
- N/A
#### Breaking changes
- N/A
#### Bug fixes
- (r948) Fix ManaToki (KO) and NewToki (KO) (issue [#202](https://github.com/Suwayomi/Tachidesk-Server/issue/202))
- (r949) Local source: fix windows paths
### Private API
- (r941) Update BytecodeEditor to use Java NIO Paths ([#200](https://github.com/Suwayomi/Tachidesk-Server/pull/200) by @Syer10)
- (r942) Gradle Updates ([#199](https://github.com/Suwayomi/Tachidesk-Server/pull/199) by @Syer10)
## Tachidesk-WebUI
#### Visible changes
- (r804) update text positioning on Reader and Player ([#35](https://github.com/Suwayomi/Tachidesk-WebUI/pull/35) by @voltrare)
- (r806) Source card for Local source is different
- (r807) add Local source guide
#### Bug fixes
- (r805) fix chapter name
#### Internal changes
- N/A
# Server: v0.5.1 + WebUI: r803
## TL;DR
- Loading sources' manga list is at least twice as fast
- Added support for Tachiyomi's Local source
- Added BasicAuth support, now you can protect your Tachidesk instance if you are running it on a public server
- Added ability to turn off cache for image requests
## Tachidesk-Server
### Public API
#### Non-breaking changes
- (r915) add BasicAuth support
- (r918) add ability to delete downloaded chapters
- (r923-r930) add Local Source
- (r938) add ability to turn off cache for image requests
#### Breaking changes
- N/A
#### Bug fixes
- (r917) detect if a downloaded chapter is missing
### Private API
- (r913) remove expand char limit on MangaTable columns
- (r914) migrate to Javalin 4
- (r921) depricate zero based chapters
- (r937) add ChapterRecognition from tachiyomi, closes #10
## Tachidesk-WebUI
#### Visible changes
- (r790) nice looking progress percentage
- (r791) show a Delete button for downloaded chapters
- (r792) Update hover effect using more of Material-UI color pallete ([#29](https://github.com/Suwayomi/Tachidesk-WebUI/pull/29) by @voltrare)
- (r793) Optimize images ([#32](https://github.com/Suwayomi/Tachidesk-WebUI/pull/32) by @phanirithvij)
- (r794) try fix #30 ([#31](https://github.com/Suwayomi/Tachidesk-WebUI/pull/31) by @phanirithvij)
- (r795) fix viewing page number when the string is long
- (r796) show proper display name for source
- (r797) fail gracefully when a thumbnail has errors
- (r798) fix when a source fails to load mangas
- (r800) add Local source ([#31](https://github.com/Suwayomi/Tachidesk-WebUI/pull/31))
- (r803) add support for useCache
#### Bug fixes
- N/A
#### Internal changes
- N/A
# Server: v0.5.0 + WebUI: r789
## TL;DR
- You can now install APK extensions from the extensions page
+10 -10
View File
@@ -3,16 +3,7 @@
|-------|----------|---------|---------|
| ![CI](https://github.com/Suwayomi/Tachidesk/actions/workflows/build_push.yml/badge.svg) | [![stable release](https://img.shields.io/github/release/Suwayomi/Tachidesk.svg?maxAge=3600&label=download)](https://github.com/Suwayomi/Tachidesk/releases) | [![preview](https://img.shields.io/badge/dynamic/json?url=https://github.com/Suwayomi/Tachidesk-preview/raw/main/index.json&label=download&query=$.latest&color=blue)](https://github.com/Suwayomi/Tachidesk-preview/releases/latest) | [![Discord](https://img.shields.io/discord/801021177333940224.svg?label=discord&labelColor=7289da&color=2c2f33&style=flat)](https://discord.gg/DDZdqZWaHA) |
# Tachidesk-Server is a server app! You may not want to Download Tachidesk-Server directly.
Yes, you need a client/user interface app as a front-end for Tachidesk-Server, if you Directly Download Tachidesk-Server you'll get a bundled version of [Tachidesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI) with it.
Here's a list of known clients/user interfaces for Tachidesk-Server:
- [Tachidesk-JUI](https://github.com/Suwayomi/Tachidesk-JUI): The "official" front-end for Tachidesk-Server, A native desktop Application.
- [Tachidesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI): The web/electrion front-end that Tachidesk-Server is traditionally shipped with.
- [Tachidesk-qtui](https://github.com/Suwayomi/Tachidesk-qtui): A C++/Qt front-end for mobile devices(Android/linux), in super early stage of development.
- [Equinox](https://github.com/Suwayomi/Equinox): A web user interface made with Vue.js, in super early stage of development.
# What is Tachidesk then?
# What is Tachidesk?
<img src="https://github.com/Suwayomi/Tachidesk/raw/master/server/src/main/resources/icon/faviconlogo.png" alt="drawing" width="200"/>
A free and open source manga reader server that runs extensions built for [Tachiyomi](https://tachiyomi.org/).
@@ -23,6 +14,15 @@ Tachidesk-Server is as multi-platform as you can get. Any platform that runs jav
Ability to read and write Tachiyomi compatible backups and syncing is a planned feature.
# Tachidesk-Server is a server app! You may not want to Download Tachidesk-Server directly.
Yes, you need a client/user interface app as a front-end for Tachidesk-Server, if you Directly Download Tachidesk-Server you'll get a bundled version of [Tachidesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI) with it.
Here's a list of known clients/user interfaces for Tachidesk-Server:
- [Tachidesk-JUI](https://github.com/Suwayomi/Tachidesk-JUI): The "official" front-end for Tachidesk-Server, A native desktop Application.
- [Tachidesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI): The web/electrion front-end that Tachidesk-Server is traditionally shipped with.
- [Tachidesk-qtui](https://github.com/Suwayomi/Tachidesk-qtui): A C++/Qt front-end for mobile devices(Android/linux), in super early stage of development.
- [Equinox](https://github.com/Suwayomi/Equinox): A web user interface made with Vue.js, in super early stage of development.
## Is this application usable? Should I test it?
Here is a list of current features:
+35 -15
View File
@@ -1,8 +1,12 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jmailen.gradle.kotlinter.tasks.FormatTask
import org.jmailen.gradle.kotlinter.tasks.LintTask
plugins {
kotlin("jvm") version kotlinVersion
kotlin("plugin.serialization") version kotlinVersion
id("org.jmailen.kotlinter") version "3.6.0"
id("com.github.gmazzo.buildconfig") version "3.0.3" apply false
}
allprojects {
@@ -12,10 +16,8 @@ allprojects {
repositories {
mavenCentral()
maven("https://maven.google.com/")
google()
maven("https://jitpack.io")
maven("https://oss.sonatype.org/content/repositories/snapshots/")
maven("https://dl.google.com/dl/android/maven2/")
}
}
@@ -27,18 +29,38 @@ val projects = listOf(
configure(projects) {
apply(plugin = "org.jetbrains.kotlin.jvm")
apply(plugin = "org.jetbrains.kotlin.plugin.serialization")
apply(plugin = "org.jmailen.kotlinter")
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
tasks.withType<KotlinCompile> {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
tasks {
withType<KotlinCompile> {
dependsOn(formatKotlin)
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs = listOf(
"-Xopt-in=kotlin.RequiresOptIn",
"-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-Xopt-in=kotlinx.coroutines.InternalCoroutinesApi",
"-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi",
)
}
}
withType<LintTask> {
source(files("src/kotlin"))
}
withType<FormatTask> {
source(files("src/kotlin"))
}
}
dependencies {
// Kotlin
implementation(kotlin("stdlib-jdk8"))
@@ -46,7 +68,7 @@ configure(projects) {
testImplementation(kotlin("test-junit5"))
// coroutines
val coroutinesVersion = "1.5.1"
val coroutinesVersion = "1.5.2"
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")
@@ -55,14 +77,13 @@ configure(projects) {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinSerializationVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-protobuf:$kotlinSerializationVersion")
// Dependency Injection
implementation("org.kodein.di:kodein-di-conf-jvm:7.7.0")
implementation("org.kodein.di:kodein-di-conf-jvm:7.8.0")
// Logging
implementation("org.slf4j:slf4j-api:1.7.30")
implementation("ch.qos.logback:logback-classic:1.2.3")
implementation("io.github.microutils:kotlin-logging:2.0.6")
implementation("org.slf4j:slf4j-api:1.7.32")
implementation("ch.qos.logback:logback-classic:1.2.6")
implementation("io.github.microutils:kotlin-logging:2.0.11")
// ReactiveX
implementation("io.reactivex:rxjava:1.3.8")
@@ -70,7 +91,7 @@ configure(projects) {
implementation("com.jakewharton.rxrelay:rxrelay:1.2.0")
// dependency both in AndroidCompat and extensions, version locked by Tachiyomi app/extensions
implementation("org.jsoup:jsoup:1.14.1")
implementation("org.jsoup:jsoup:1.14.2")
// dependency of :AndroidCompat:Config
implementation("com.typesafe:config:1.4.1")
@@ -87,8 +108,7 @@ configure(projects) {
// APK parser
implementation("net.dongliu:apk-parser:2.6.10")
// dependency both in AndroidCompat and server, version locked by javalin
implementation("com.fasterxml.jackson.core:jackson-annotations:2.10.3")
implementation("com.fasterxml.jackson.core:jackson-annotations:2.12.4")
}
}
+2 -2
View File
@@ -12,9 +12,9 @@ const val kotlinVersion = "1.5.30"
const val MainClass = "suwayomi.tachidesk.MainKt"
// should be bumped with each stable release
val tachideskVersion = System.getenv("ProductVersion") ?: "v0.5.0"
val tachideskVersion = System.getenv("ProductVersion") ?: "v0.5.3"
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r789"
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r809"
// counts commits on the master branch
val tachideskRevision = runCatching {
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.1.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+2 -2
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#/bin/bash
# Copyright (C) Contributors to the Suwayomi project
#
@@ -24,7 +24,7 @@ elif [ $1 = "macOS-arm64" ]; then
jre="zulu8.56.0.23-ca-jre8.0.302-macosx_aarch64.tar.gz"
jre_release="zulu8.56.0.23-ca-jre8.0.302-macosx_aarch64"
jre_url="https://cdn.azul.com/zulu/bin/$jre"
jre_dir="$jre_release"
jre_dir="$jre_release/zulu-8.jre"
electron="electron-$electron_version-darwin-arm64.zip"
else
echo "Unsupported arch value: $1"
+23 -61
View File
@@ -1,25 +1,10 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import org.jmailen.gradle.kotlinter.tasks.FormatTask
import org.jmailen.gradle.kotlinter.tasks.LintTask
import de.undercouch.gradle.tasks.download.Download
import java.time.Instant
plugins {
application
kotlin("plugin.serialization")
id("com.github.johnrengelman.shadow") version "7.0.0"
id("org.jmailen.kotlinter") version "3.6.0"
id("com.github.gmazzo.buildconfig") version "3.0.2"
}
repositories {
maven {
url = uri("https://repo1.maven.org/maven2/")
}
maven {
url = uri("https://jitpack.io")
}
id("com.github.gmazzo.buildconfig")
}
dependencies {
@@ -31,10 +16,11 @@ dependencies {
implementation("com.squareup.okio:okio:2.10.0")
// Javalin api
implementation("io.javalin:javalin:3.13.11")
implementation("io.javalin:javalin:4.0.0")
// jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
implementation("com.fasterxml.jackson.core:jackson-databind:2.10.3")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.10.3")
val jacksonVersion = "2.12.4"
implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion")
// Exposed ORM
val exposedVersion = "0.34.1"
@@ -46,28 +32,29 @@ dependencies {
implementation("com.h2database:h2:1.4.200")
// Exposed Migrations
val exposedMigrationsVersion = "3.1.2"
implementation("com.github.Suwayomi:exposed-migrations:$exposedMigrationsVersion")
implementation("com.github.Suwayomi:exposed-migrations:3.1.2")
// tray icon
implementation("com.dorkbox:SystemTray:4.1")
implementation("com.dorkbox:Utilities:1.9") // version locked by SystemTray
// dependencies of Tachiyomi extensions, some are duplicate, keeping it here for reference
implementation("com.github.inorichi.injekt:injekt-core:65b0440")
implementation("com.squareup.okhttp3:okhttp:4.9.1")
implementation("io.reactivex:rxjava:1.3.8")
implementation("org.jsoup:jsoup:1.14.1")
implementation("com.google.code.gson:gson:2.8.7")
implementation("org.jsoup:jsoup:1.14.2")
implementation("com.google.code.gson:gson:2.8.8")
implementation("com.github.salomonbrys.kotson:kotson:2.5.0")
// Sort
implementation("com.github.gpanther:java-nat-sort:natural-comparator-1.1")
// asm for ByteCodeEditor(fixing SimpleDateFormat) (must match Dex2Jar version)
implementation("org.ow2.asm:asm:9.2")
// extracting zip files
// Disk & File
implementation("net.lingala.zip4j:zip4j:2.9.0")
implementation("com.github.junrar:junrar:7.4.0")
// CloudflareInterceptor
implementation("net.sourceforge.htmlunit:htmlunit:2.52.0")
@@ -82,6 +69,7 @@ dependencies {
// uncomment to test extensions directly
// implementation(fileTree("lib/"))
implementation(kotlin("script-runtime"))
}
application {
@@ -129,48 +117,29 @@ tasks {
shadowJar {
manifest {
attributes(
mapOf(
"Main-Class" to MainClass,
"Implementation-Title" to rootProject.name,
"Implementation-Vendor" to "The Suwayomi Project",
"Specification-Version" to tachideskVersion,
"Implementation-Version" to tachideskRevision
)
"Main-Class" to MainClass,
"Implementation-Title" to rootProject.name,
"Implementation-Vendor" to "The Suwayomi Project",
"Specification-Version" to tachideskVersion,
"Implementation-Version" to tachideskRevision
)
}
archiveBaseName.set(rootProject.name)
archiveVersion.set(tachideskVersion)
archiveClassifier.set(tachideskRevision)
}
withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf(
"-Xopt-in=kotlin.RequiresOptIn",
"-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
"-Xopt-in=kotlinx.coroutines.InternalCoroutinesApi",
"-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi",
)
}
destinationDirectory.set(File("$rootDir/server/build"))
}
test {
useJUnit()
}
withType<ShadowJar> {
destinationDirectory.set(File("$rootDir/server/build"))
}
named("run") {
dependsOn("formatKotlin", "lintKotlin")
}
named<Copy>("processResources") {
duplicatesStrategy = DuplicatesStrategy.INCLUDE
mustRunAfter("downloadWebUI")
}
register<de.undercouch.gradle.tasks.download.Download>("downloadWebUI") {
register<Download>("downloadWebUI") {
src("https://github.com/Suwayomi/Tachidesk-WebUI-preview/releases/download/$webUIRevisionTag/Tachidesk-WebUI-$webUIRevisionTag.zip")
dest("src/main/resources/WebUI.zip")
@@ -185,8 +154,9 @@ tasks {
it.readText().trim()
}
if (zipRevision == webUIRevisionTag)
if (zipRevision == webUIRevisionTag) {
shouldOverwrite = false
}
}
return shouldOverwrite
@@ -194,12 +164,4 @@ tasks {
overwrite(shouldOverwrite())
}
withType<LintTask> {
source(files("src/kotlin"))
}
withType<FormatTask> {
source(files("src/kotlin"))
}
}
@@ -1,358 +0,0 @@
package eu.kanade.tachiyomi.source
import android.content.Context
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import rx.Observable
// import com.github.junrar.Archive
// import com.google.gson.JsonParser
// import eu.kanade.tachiyomi.R
// import eu.kanade.tachiyomi.source.model.Filter
// import eu.kanade.tachiyomi.source.model.FilterList
// import eu.kanade.tachiyomi.source.model.MangasPage
// import eu.kanade.tachiyomi.source.model.Page
// import eu.kanade.tachiyomi.source.model.SChapter
// import eu.kanade.tachiyomi.source.model.SManga
// import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
// import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
// import eu.kanade.tachiyomi.util.storage.DiskUtil
// import eu.kanade.tachiyomi.util.storage.EpubFile
// import eu.kanade.tachiyomi.util.system.ImageUtil
// import rx.Observable
// import timber.log.Timber
// import java.io.File
// import java.io.FileInputStream
// import java.io.InputStream
// import java.util.Locale
// import java.util.concurrent.TimeUnit
// import java.util.zip.ZipFile
class LocalSource(private val context: Context) : CatalogueSource {
companion object {
const val ID = 0L
// const val HELP_URL = "https://tachiyomi.org/help/guides/reading-local-manga/"
//
// private const val COVER_NAME = "cover.jpg"
// private val SUPPORTED_ARCHIVE_TYPES = setOf("zip", "rar", "cbr", "cbz", "epub")
//
// private val POPULAR_FILTERS = FilterList(OrderBy())
// private val LATEST_FILTERS = FilterList(OrderBy().apply { state = Filter.Sort.Selection(1, false) })
// private val LATEST_THRESHOLD = TimeUnit.MILLISECONDS.convert(7, TimeUnit.DAYS)
//
// fun updateCover(context: Context, manga: SManga, input: InputStream): File? {
// val dir = getBaseDirectories(context).firstOrNull()
// if (dir == null) {
// input.close()
// return null
// }
// val cover = File("${dir.absolutePath}/${manga.url}", COVER_NAME)
//
// // It might not exist if using the external SD card
// cover.parentFile?.mkdirs()
// input.use {
// cover.outputStream().use {
// input.copyTo(it)
// }
// }
// return cover
// }
//
// private fun getBaseDirectories(context: Context): List<File> {
// val c = context.getString(R.string.app_name) + File.separator + "local"
// return DiskUtil.getExternalStorages(context).map { File(it.absolutePath, c) }
// }
}
override val id = ID
override val name = "Local source"
override val lang = ""
override val supportsLatest = true
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
TODO("Not yet implemented")
}
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
TODO("Not yet implemented")
}
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
TODO("Not yet implemented")
}
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
TODO("Not yet implemented")
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
TODO("Not yet implemented")
}
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
TODO("Not yet implemented")
}
override fun getFilterList(): FilterList {
TODO("Not yet implemented")
}
//
// override fun toString() = context.getString(R.string.local_source)
//
// override fun fetchPopularManga(page: Int) = fetchSearchManga(page, "", POPULAR_FILTERS)
//
// override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
// val baseDirs = getBaseDirectories(context)
//
// val time = if (filters === LATEST_FILTERS) System.currentTimeMillis() - LATEST_THRESHOLD else 0L
// var mangaDirs = baseDirs
// .asSequence()
// .mapNotNull { it.listFiles()?.toList() }
// .flatten()
// .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.toLowerCase(Locale.ENGLISH) }
// } else {
// mangaDirs.sortedByDescending { it.name.toLowerCase(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
// for (dir in baseDirs) {
// val cover = File("${dir.absolutePath}/$url", COVER_NAME)
// if (cover.exists()) {
// thumbnail_url = cover.absolutePath
// break
// }
// }
//
// 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 {
// val dest = updateCover(chapter, this)
// thumbnail_url = dest?.absolutePath
// } catch (e: Exception) {
// Timber.e(e)
// }
// }
// }
// }
// }
//
// return Observable.just(MangasPage(mangas.toList(), false))
// }
//
// override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", LATEST_FILTERS)
//
// override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
// getBaseDirectories(context)
// .asSequence()
// .mapNotNull { File(it, manga.url).listFiles()?.toList() }
// .flatten()
// .firstOrNull { it.extension == "json" }
// ?.apply {
// val reader = this.inputStream().bufferedReader()
// val json = JsonParser.parseReader(reader).asJsonObject
//
// manga.title = json["title"]?.asString ?: manga.title
// manga.author = json["author"]?.asString ?: manga.author
// manga.artist = json["artist"]?.asString ?: manga.artist
// manga.description = json["description"]?.asString ?: manga.description
// manga.genre = json["genre"]?.asJsonArray?.joinToString(", ") { it.asString }
// ?: manga.genre
// manga.status = json["status"]?.asInt ?: manga.status
// }
//
// return Observable.just(manga)
// }
//
// override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
// val chapters = getBaseDirectories(context)
// .asSequence()
// .mapNotNull { File(it, manga.url).listFiles()?.toList() }
// .flatten()
// .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(
// Comparator { 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(' ', '-', '_', ',', ':')
// }
//
// override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
// return Observable.error(Exception("Unused"))
// }
//
// private fun isSupportedFile(extension: String): Boolean {
// return extension.toLowerCase() in SUPPORTED_ARCHIVE_TYPES
// }
//
// fun getFormat(chapter: SChapter): Format {
// val baseDirs = getBaseDirectories(context)
//
// for (dir in baseDirs) {
// val chapFile = File(dir, chapter.url)
// if (!chapFile.exists()) continue
//
// return getFormat(chapFile)
// }
// throw Exception("Chapter not found")
// }
//
// private fun getFormat(file: File): Format {
// val extension = file.extension
// return if (file.isDirectory) {
// Format.Directory(file)
// } else if (extension.equals("zip", true) || extension.equals("cbz", true)) {
// Format.Zip(file)
// } else if (extension.equals("rar", true) || extension.equals("cbr", true)) {
// Format.Rar(file)
// } else if (extension.equals("epub", true)) {
// Format.Epub(file)
// } 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(context, 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(context, 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(context, manga, archive.getInputStream(it)) }
// }
// }
// is Format.Epub -> {
// EpubFile(format.file).use { epub ->
// val entry = epub.getImagesFromPages()
// .firstOrNull()
// ?.let { epub.getEntry(it) }
//
// entry?.let { updateCover(context, manga, epub.getInputStream(it)) }
// }
// }
// }
// }
//
// private class OrderBy : Filter.Sort("Order by", arrayOf("Title", "Date"), Selection(0, true))
//
// override fun getFilterList() = FilterList(OrderBy())
//
// 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()
// }
}
@@ -0,0 +1,477 @@
package eu.kanade.tachiyomi.source.local
import com.github.junrar.Archive
import eu.kanade.tachiyomi.source.local.FileSystemInterceptor.fakeUrlFrom
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Directory
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Epub
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Rar
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Zip
import eu.kanade.tachiyomi.source.local.loader.EpubPageLoader
import eu.kanade.tachiyomi.source.local.loader.RarPageLoader
import eu.kanade.tachiyomi.source.local.loader.ZipPageLoader
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.EpubFile
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromStream
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import mu.KotlinLogging
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.asResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import okio.buffer
import okio.source
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import rx.Observable
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.InputStream
import java.net.URLDecoder
import java.util.Locale
import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile
class LocalSource : HttpSource() {
companion object {
const val ID = 0L
const val LANG = "localsourcelang"
const val NAME = "Local source"
const val EXTENSION_NAME = "Local Source fake extension"
const val HELP_URL = "https://tachiyomi.org/help/guides/local-manga/"
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 applicationDirs by DI.global.instance<ApplicationDirs>()
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 addDbRecords() {
transaction {
val sourceRecord = SourceTable.select { SourceTable.id eq ID }.firstOrNull()
if (sourceRecord == null) {
// must do this to avoid database integrity errors
val extensionId = ExtensionTable.insertAndGetId {
it[apkName] = "localSource"
it[name] = EXTENSION_NAME
it[pkgName] = LocalSource::class.java.`package`.name
it[versionName] = "1.2"
it[versionCode] = 0
it[lang] = LANG
it[isNsfw] = false
it[isInstalled] = true
}
SourceTable.insert {
it[id] = ID
it[name] = NAME
it[lang] = LANG
it[extension] = extensionId
it[isNsfw] = false
}
}
}
}
}
override val id = ID
override val name = NAME
override val lang = LANG
override val baseUrl: String = ""
override val supportsLatest = true
override val client: OkHttpClient = super.client.newBuilder()
.addInterceptor(FileSystemInterceptor)
.build()
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 = fakeUrlFrom(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 {
val dest = updateCover(chapter, this)
thumbnail_url = dest?.absolutePath?.let { fakeUrlFrom(it) }
} 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
}
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 }.mapIndexed { index, page ->
Page(
index,
imageUrl = fakeUrlFrom(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()
}
// ///////////////////// Not used ///////////////////// //
override fun mangaDetailsParse(response: Response): SManga = throw Exception("Not used")
override fun chapterListParse(response: Response): List<SChapter> = throw Exception("Not used")
override fun pageListParse(response: Response): List<Page> = throw Exception("Not used")
override fun imageUrlParse(response: Response): String = throw Exception("Not used")
override fun popularMangaRequest(page: Int): Request = throw Exception("Not used")
override fun popularMangaParse(response: Response): MangasPage = throw Exception("Not used")
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request =
throw Exception("Not used")
override fun searchMangaParse(response: Response): MangasPage = throw Exception("Not used")
override fun latestUpdatesRequest(page: Int): Request = throw Exception("Not used")
override fun latestUpdatesParse(response: Response): MangasPage = throw Exception("Not used")
}
private object FileSystemInterceptor : Interceptor {
fun fakeUrlFrom(path: String): String = "http://$path"
private fun restoreFilePath(url: String): String {
val path = URLDecoder.decode(url.replaceFirst("http://", ""), "UTF-8")
// Windows
if (System.getProperty("os.name").lowercase().startsWith("win")) {
// convert paths like "c/Users/..." to "c:/Users/..."
return StringBuilder(path).insert(1, ":").toString()
}
return "/$path"
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val url = request.url
val filePath = restoreFilePath(url.toString())
return try {
Response.Builder()
.body(File(filePath).source().buffer().asResponseBody())
.code(200)
.message("Some file")
.protocol(Protocol.HTTP_1_0)
.request(request)
.build()
} catch (e: FileNotFoundException) {
Response.Builder()
.body("".toResponseBody())
.code(404)
.message(e.message ?: "File not found ($filePath)")
.protocol(Protocol.HTTP_1_0)
.request(request)
.build()
}
}
}
@@ -0,0 +1,31 @@
package eu.kanade.tachiyomi.source.local.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.storage.EpubFile
import java.io.File
/**
* Loader used to load a chapter from a .epub file.
*/
class EpubPageLoader(file: File) : PageLoader {
/**
* The epub file.
*/
private val epub = EpubFile(file)
/**
* Returns an observable containing the pages found on this zip archive ordered with a natural
* comparator.
*/
override fun getPages(): List<ReaderPage> {
return epub.getImagesFromPages()
.mapIndexed { i, path ->
val streamFn = { epub.getInputStream(epub.getEntry(path)!!) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
}
}
@@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.source.local.loader
// adapted from eu.kanade.tachiyomi.ui.reader.loader.PageLoader
interface PageLoader {
/**
* Returns an observable containing the list of pages of a chapter. Only the first emission
* will be used.
*/
fun getPages(): List<ReaderPage>
}
@@ -0,0 +1,63 @@
package eu.kanade.tachiyomi.source.local.loader
import com.github.junrar.Archive
import com.github.junrar.rarfile.FileHeader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import java.io.File
import java.io.InputStream
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.util.concurrent.Executors
/**
* Loader used to load a chapter from a .rar or .cbr file.
*/
class RarPageLoader(file: File) : PageLoader {
/**
* The rar archive to load pages from.
*/
private val archive = Archive(file)
/**
* Pool for copying compressed files to an input stream.
*/
private val pool = Executors.newFixedThreadPool(1)
/**
* Returns an observable containing the pages found on this rar archive ordered with a natural
* comparator.
*/
override fun getPages(): List<ReaderPage> {
return archive.fileHeaders
.filter { !it.isDirectory && ImageUtil.isImage(it.fileName) { archive.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.mapIndexed { i, header ->
val streamFn = { getStream(header) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
}
/**
* Returns an input stream for the given [header].
*/
private fun getStream(header: FileHeader): InputStream {
val pipeIn = PipedInputStream()
val pipeOut = PipedOutputStream(pipeIn)
pool.execute {
try {
pipeOut.use {
archive.extractFile(header, it)
}
} catch (e: Exception) {
}
}
return pipeIn
}
}
@@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.source.local.loader
import eu.kanade.tachiyomi.source.model.Page
import java.io.InputStream
class ReaderPage(
index: Int,
url: String = "",
imageUrl: String? = null,
var stream: (() -> InputStream)? = null
) : Page(index, url, imageUrl, null)
@@ -0,0 +1,31 @@
package eu.kanade.tachiyomi.source.local.loader
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import java.io.File
import java.util.zip.ZipFile
class ZipPageLoader(file: File) : PageLoader {
/**
* The zip file to load pages from.
*/
private val zip = ZipFile(file)
/**
* Returns an observable containing the pages found on this zip archive ordered with a natural
* comparator.
*/
override fun getPages(): List<ReaderPage> {
return zip.entries().toList()
.filter { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
.sortedWith { f1, f2 -> f1.name.compareToCaseInsensitiveNaturalOrder(f2.name) }
.mapIndexed { i, entry ->
val streamFn = { zip.getInputStream(entry) }
ReaderPage(i).apply {
stream = streamFn
status = Page.READY
}
}
}
}
@@ -0,0 +1,152 @@
package eu.kanade.tachiyomi.util.chapter
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
/**
* -R> = regex conversion.
*/
object ChapterRecognition {
/**
* All cases with Ch.xx
* Mokushiroku Alice Vol.1 Ch. 4: Misrepresentation -R> 4
*/
private val basic = Regex("""(?<=ch\.) *([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?""")
/**
* Regex used when only one number occurrence
* Example: Bleach 567: Down With Snowwhite -R> 567
*/
private val occurrence = Regex("""([0-9]+)(\.[0-9]+)?(\.?[a-z]+)?""")
/**
* 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
* 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]+""")
/**
* Regex used to remove unwanted whitespace
* Example One Piece 12 special -R> One Piece 12special
*/
private val unwantedWhiteSpace = Regex("""(\s)(extra|special|omake)""")
fun parseChapterNumber(chapter: SChapter, manga: SManga) {
// If chapter number is known return.
if (chapter.chapter_number == -2f || chapter.chapter_number > -1f) {
return
}
// Get chapter title with lower case
var name = chapter.name.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.
val nameWithoutManga = name.replace(manga.title.lowercase(), "").trim()
// Check if first value is number after title remove.
if (updateChapter(withoutManga.find(nameWithoutManga), chapter)) {
return
}
// Take the first number encountered.
if (updateChapter(occurrence.find(nameWithoutManga), chapter)) {
return
}
}
/**
* Check if volume is found and update chapter
* @param match result of regex
* @param chapter chapter object
* @return true if volume is found
*/
private fun updateChapter(match: MatchResult?, chapter: SChapter): Boolean {
match?.let {
val initial = it.groups[1]?.value?.toFloat()!!
val subChapterDecimal = it.groups[2]?.value
val subChapterAlpha = it.groups[3]?.value
val addition = checkForDecimal(subChapterDecimal, subChapterAlpha)
chapter.chapter_number = initial.plus(addition)
return true
}
return false
}
/**
* Check for decimal in received strings
* @param decimal decimal value of regex
* @param alpha alpha value of regex
* @return decimal/alpha float value
*/
private fun checkForDecimal(decimal: String?, alpha: String?): Float {
if (!decimal.isNullOrEmpty()) {
return decimal.toFloat()
}
if (!alpha.isNullOrEmpty()) {
if (alpha.contains("extra")) {
return .99f
}
if (alpha.contains("omake")) {
return .98f
}
if (alpha.contains("special")) {
return .97f
}
return if (alpha[0] == '.') {
// Take value after (.)
parseAlphaPostFix(alpha[1])
} else {
parseAlphaPostFix(alpha[0])
}
}
return .0f
}
/**
* x.a -> x.1, x.b -> x.2, etc
*/
private fun parseAlphaPostFix(alpha: Char): Float {
return ("0." + (alpha.code - 96).toString()).toFloat()
}
}
@@ -0,0 +1,58 @@
package eu.kanade.tachiyomi.util.lang
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
import kotlin.math.floor
/**
* Replaces the given string to have at most [count] characters using [replacement] at its end.
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
*/
fun String.chop(count: Int, replacement: String = ""): String {
return if (length > count) {
take(count - replacement.length) + replacement
} else {
this
}
}
/**
* Replaces the given string to have at most [count] characters using [replacement] near the center.
* If [replacement] is longer than [count] an exception will be thrown when `length > count`.
*/
fun String.truncateCenter(count: Int, replacement: String = "..."): String {
if (length <= count) {
return this
}
val pieceLength: Int = floor((count - replacement.length).div(2.0)).toInt()
return "${take(pieceLength)}$replacement${takeLast(pieceLength)}"
}
/**
* Case-insensitive natural comparator for strings.
*/
fun String.compareToCaseInsensitiveNaturalOrder(other: String): Int {
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
return comparator.compare(this, other)
}
/**
* Returns the size of the string as the number of bytes.
*/
fun String.byteSize(): Int {
return toByteArray(Charsets.UTF_8).size
}
/**
* Returns a string containing the first [n] bytes from this string, or the entire string if this
* string is shorter.
*/
fun String.takeBytes(n: Int): String {
val bytes = toByteArray(Charsets.UTF_8)
return if (bytes.size <= n) {
this
} else {
bytes.decodeToString(endIndex = n).replace("\uFFFD", "")
}
}
@@ -0,0 +1,215 @@
package eu.kanade.tachiyomi.util.storage
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import java.io.Closeable
import java.io.File
import java.io.InputStream
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
/**
* Wrapper over ZipFile to load files in epub format.
*/
class EpubFile(file: File) : Closeable {
/**
* Zip file of this epub.
*/
private val zip = ZipFile(file)
/**
* Path separator used by this epub.
*/
private val pathSeparator = getPathSeparator()
/**
* Closes the underlying zip file.
*/
override fun close() {
zip.close()
}
/**
* Returns an input stream for reading the contents of the specified zip file entry.
*/
fun getInputStream(entry: ZipEntry): InputStream {
return zip.getInputStream(entry)
}
/**
* Returns the zip file entry for the specified name, or null if not found.
*/
fun getEntry(name: String): ZipEntry? {
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.
*/
fun getImagesFromPages(): List<String> {
val ref = getPackageHref()
val doc = getPackageDocument(ref)
val pages = getPagesFromDocument(doc)
return getImagesFromPages(pages, ref)
}
/**
* Returns the path to the package document.
*/
private fun getPackageHref(): String {
val meta = zip.getEntry(resolveZipPath("META-INF", "container.xml"))
if (meta != null) {
val metaDoc = zip.getInputStream(meta).use { Jsoup.parse(it, null, "") }
val path = metaDoc.getElementsByTag("rootfile").first()?.attr("full-path")
if (path != null) {
return path
}
}
return resolveZipPath("OEBPS", "content.opf")
}
/**
* Returns the package document where all the files are listed.
*/
private fun getPackageDocument(ref: String): Document {
val entry = zip.getEntry(ref)
return zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
}
/**
* Returns all the pages from the epub.
*/
private fun getPagesFromDocument(document: Document): List<String> {
val pages = document.select("manifest > item")
.filter { "application/xhtml+xml" == it.attr("media-type") }
.associateBy { it.attr("id") }
val spine = document.select("spine > itemref").map { it.attr("idref") }
return spine.mapNotNull { pages[it] }.map { it.attr("href") }
}
/**
* Returns all the images contained in every page from the epub.
*/
private fun getImagesFromPages(pages: List<String>, packageHref: String): List<String> {
val result = mutableListOf<String>()
val basePath = getParentDirectory(packageHref)
pages.forEach { page ->
val entryPath = resolveZipPath(basePath, page)
val entry = zip.getEntry(entryPath)
val document = zip.getInputStream(entry).use { Jsoup.parse(it, null, "") }
val imageBasePath = getParentDirectory(entryPath)
document.allElements.forEach {
if (it.tagName() == "img") {
result.add(resolveZipPath(imageBasePath, it.attr("src")))
} else if (it.tagName() == "image") {
result.add(resolveZipPath(imageBasePath, it.attr("xlink:href")))
}
}
}
return result
}
/**
* Returns the path separator used by the epub file.
*/
private fun getPathSeparator(): String {
val meta = zip.getEntry("META-INF\\container.xml")
return if (meta != null) {
"\\"
} else {
"/"
}
}
/**
* Resolves a zip path from base and relative components and a path separator.
*/
private fun resolveZipPath(basePath: String, relativePath: String): String {
if (relativePath.startsWith(pathSeparator)) {
// Path is absolute, so return as-is.
return relativePath
}
var fixedBasePath = basePath.replace(pathSeparator, File.separator)
if (!fixedBasePath.startsWith(File.separator)) {
fixedBasePath = "${File.separator}$fixedBasePath"
}
val fixedRelativePath = relativePath.replace(pathSeparator, File.separator)
val resolvedPath = File(fixedBasePath, fixedRelativePath).canonicalPath
return resolvedPath.replace(File.separator, pathSeparator).substring(1)
}
/**
* Gets the parent directory of a path.
*/
private fun getParentDirectory(path: String): String {
val separatorIndex = path.lastIndexOf(pathSeparator)
return if (separatorIndex >= 0) {
path.substring(0, separatorIndex)
} else {
""
}
}
}
@@ -28,7 +28,7 @@ object AnimeAPI {
fun defineEndpoints(app: Javalin) {
// list all extensions
app.get("/api/v1/anime/extension/list") { ctx ->
ctx.json(
ctx.future(
future {
getExtensionList()
}
@@ -36,10 +36,10 @@ object AnimeAPI {
}
// install extension identified with "pkgName"
app.get("/api/v1/anime/extension/install/:pkgName") { ctx ->
app.get("/api/v1/anime/extension/install/{pkgName}") { ctx ->
val pkgName = ctx.pathParam("pkgName")
ctx.json(
ctx.future(
future {
installExtension(pkgName)
}
@@ -47,10 +47,10 @@ object AnimeAPI {
}
// update extension identified with "pkgName"
app.get("/api/v1/anime/extension/update/:pkgName") { ctx ->
app.get("/api/v1/anime/extension/update/{pkgName}") { ctx ->
val pkgName = ctx.pathParam("pkgName")
ctx.json(
ctx.future(
future {
updateExtension(pkgName)
}
@@ -58,7 +58,7 @@ object AnimeAPI {
}
// uninstall extension identified with "pkgName"
app.get("/api/v1/anime/extension/uninstall/:pkgName") { ctx ->
app.get("/api/v1/anime/extension/uninstall/{pkgName}") { ctx ->
val pkgName = ctx.pathParam("pkgName")
uninstallExtension(pkgName)
@@ -66,10 +66,10 @@ object AnimeAPI {
}
// icon for extension named `apkName`
app.get("/api/v1/anime/extension/icon/:apkName") { ctx -> // TODO: move to pkgName
app.get("/api/v1/anime/extension/icon/{apkName}") { ctx -> // TODO: move to pkgName
val apkName = ctx.pathParam("apkName")
ctx.result(
ctx.future(
future { getExtensionIcon(apkName) }
.thenApply {
ctx.header("content-type", it.second)
@@ -84,16 +84,16 @@ object AnimeAPI {
}
// fetch source with id `sourceId`
app.get("/api/v1/anime/source/:sourceId") { ctx ->
app.get("/api/v1/anime/source/{sourceId}") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
ctx.json(getAnimeSource(sourceId))
}
// popular animes from source with id `sourceId`
app.get("/api/v1/anime/source/:sourceId/popular/:pageNum") { ctx ->
app.get("/api/v1/anime/source/{sourceId}/popular/{pageNum}") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(
ctx.future(
future {
getAnimeList(sourceId, pageNum, popular = true)
}
@@ -101,10 +101,10 @@ object AnimeAPI {
}
// latest animes from source with id `sourceId`
app.get("/api/v1/anime/source/:sourceId/latest/:pageNum") { ctx ->
app.get("/api/v1/anime/source/{sourceId}/latest/{pageNum}") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(
ctx.future(
future {
getAnimeList(sourceId, pageNum, popular = false)
}
@@ -112,11 +112,11 @@ object AnimeAPI {
}
// get anime info
app.get("/api/v1/anime/anime/:animeId/") { ctx ->
app.get("/api/v1/anime/anime/{animeId}/") { ctx ->
val animeId = ctx.pathParam("animeId").toInt()
val onlineFetch = ctx.queryParam("onlineFetch", "false").toBoolean()
val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean() ?: false
ctx.json(
ctx.future(
future {
getAnime(animeId, onlineFetch)
}
@@ -124,10 +124,10 @@ object AnimeAPI {
}
// anime thumbnail
app.get("api/v1/anime/anime/:animeId/thumbnail") { ctx ->
app.get("api/v1/anime/anime/{animeId}/thumbnail") { ctx ->
val animeId = ctx.pathParam("animeId").toInt()
ctx.result(
ctx.future(
future { getAnimeThumbnail(animeId) }
.thenApply {
ctx.header("content-type", it.second)
@@ -137,13 +137,13 @@ object AnimeAPI {
}
//
// // list manga's categories
// app.get("api/v1/manga/:mangaId/category/") { ctx ->
// app.get("api/v1/manga/{mangaId}/category/") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
// ctx.json(getMangaCategories(mangaId))
// }
//
// // adds the manga to category
// app.get("api/v1/manga/:mangaId/category/:categoryId") { ctx ->
// app.get("api/v1/manga/{mangaId}/category/{categoryId}") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
// val categoryId = ctx.pathParam("categoryId").toInt()
// addMangaToCategory(mangaId, categoryId)
@@ -151,7 +151,7 @@ object AnimeAPI {
// }
//
// // removes the manga from the category
// app.delete("api/v1/manga/:mangaId/category/:categoryId") { ctx ->
// app.delete("api/v1/manga/{mangaId}/category/{categoryId}") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
// val categoryId = ctx.pathParam("categoryId").toInt()
// removeMangaFromCategory(mangaId, categoryId)
@@ -159,23 +159,23 @@ object AnimeAPI {
// }
//
// get episode list when showing a anime
app.get("/api/v1/anime/anime/:animeId/episodes") { ctx ->
app.get("/api/v1/anime/anime/{animeId}/episodes") { ctx ->
val animeId = ctx.pathParam("animeId").toInt()
val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean()
ctx.json(future { getEpisodeList(animeId, onlineFetch) })
ctx.future(future { getEpisodeList(animeId, onlineFetch) })
}
// used to display a episode, get a episode in order to show it's <Quality pending>
app.get("/api/v1/anime/anime/:animeId/episode/:episodeIndex") { ctx ->
app.get("/api/v1/anime/anime/{animeId}/episode/{episodeIndex}") { ctx ->
val episodeIndex = ctx.pathParam("episodeIndex").toInt()
val animeId = ctx.pathParam("animeId").toInt()
ctx.json(future { getEpisode(episodeIndex, animeId) })
ctx.future(future { getEpisode(episodeIndex, animeId) })
}
// used to modify a episode's parameters
app.patch("/api/v1/anime/anime/:animeId/episode/:episodeIndex") { ctx ->
app.patch("/api/v1/anime/anime/{animeId}/episode/{episodeIndex}") { ctx ->
val episodeIndex = ctx.pathParam("episodeIndex").toInt()
val animeId = ctx.pathParam("animeId").toInt()
@@ -190,7 +190,7 @@ object AnimeAPI {
}
//
// // get page at index "index"
// app.get("/api/v1/manga/:mangaId/chapter/:chapterIndex/page/:index") { ctx ->
// app.get("/api/v1/manga/{mangaId}/chapter/{chapterIndex}/page/{index}") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
// val chapterIndex = ctx.pathParam("chapterIndex").toInt()
// val index = ctx.pathParam("index").toInt()
@@ -205,49 +205,49 @@ object AnimeAPI {
// }
//
// // submit a chapter for download
// app.put("/api/v1/manga/:mangaId/chapter/:chapterIndex/download") { ctx ->
// app.put("/api/v1/manga/{mangaId}/chapter/{chapterIndex}/download") { ctx ->
// // TODO
// }
//
// // cancel a chapter download
// app.delete("/api/v1/manga/:mangaId/chapter/:chapterIndex/download") { ctx ->
// app.delete("/api/v1/manga/{mangaId}/chapter/{chapterIndex}/download") { ctx ->
// // TODO
// }
//
// // global search, Not implemented yet
// app.get("/api/v1/search/:searchTerm") { ctx ->
// app.get("/api/v1/search/{searchTerm}") { ctx ->
// val searchTerm = ctx.pathParam("searchTerm")
// ctx.json(sourceGlobalSearch(searchTerm))
// }
//
// single source search
app.get("/api/v1/anime/source/:sourceId/search/:searchTerm/:pageNum") { ctx ->
app.get("/api/v1/anime/source/{sourceId}/search/{searchTerm}/{pageNum}") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
val searchTerm = ctx.pathParam("searchTerm")
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(future { sourceSearch(sourceId, searchTerm, pageNum) })
ctx.future(future { sourceSearch(sourceId, searchTerm, pageNum) })
}
//
// // source filter list
// app.get("/api/v1/source/:sourceId/filters/") { ctx ->
// app.get("/api/v1/source/{sourceId}/filters/") { ctx ->
// val sourceId = ctx.pathParam("sourceId").toLong()
// ctx.json(sourceFilters(sourceId))
// }
//
// // adds the manga to library
// app.get("api/v1/manga/:mangaId/library") { ctx ->
// app.get("api/v1/manga/{mangaId}/library") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
//
// ctx.result(
// ctx.future(
// JavalinSetup.future { addMangaToLibrary(mangaId) }
// )
// }
//
// // removes the manga from the library
// app.delete("api/v1/manga/:mangaId/library") { ctx ->
// app.delete("api/v1/manga/{mangaId}/library") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
//
// ctx.result(
// ctx.future(
// JavalinSetup.future { removeMangaFromLibrary(mangaId) }
// )
// }
@@ -275,7 +275,7 @@ object AnimeAPI {
// }
//
// // category modification
// app.patch("/api/v1/category/:categoryId") { ctx ->
// app.patch("/api/v1/category/{categoryId}") { ctx ->
// val categoryId = ctx.pathParam("categoryId").toInt()
// val name = ctx.formParam("name")
// val isDefault = ctx.formParam("default")?.toBoolean()
@@ -284,7 +284,7 @@ object AnimeAPI {
// }
//
// // category re-ordering
// app.patch("/api/v1/category/:categoryId/reorder") { ctx ->
// app.patch("/api/v1/category/{categoryId}/reorder") { ctx ->
// val categoryId = ctx.pathParam("categoryId").toInt()
// val from = ctx.formParam("from")!!.toInt()
// val to = ctx.formParam("to")!!.toInt()
@@ -293,21 +293,21 @@ object AnimeAPI {
// }
//
// // category delete
// app.delete("/api/v1/category/:categoryId") { ctx ->
// app.delete("/api/v1/category/{categoryId}") { ctx ->
// val categoryId = ctx.pathParam("categoryId").toInt()
// Category.removeCategory(categoryId)
// ctx.status(200)
// }
//
// // returns the manga list associated with a category
// app.get("/api/v1/category/:categoryId") { ctx ->
// app.get("/api/v1/category/{categoryId}") { ctx ->
// val categoryId = ctx.pathParam("categoryId").toInt()
// ctx.json(getCategoryMangaList(categoryId))
// }
//
// // expects a Tachiyomi legacy backup json in the body
// app.post("/api/v1/backup/legacy/import") { ctx ->
// ctx.result(
// ctx.future(
// future {
// restoreLegacyBackup(ctx.bodyAsInputStream())
// }
@@ -316,7 +316,7 @@ object AnimeAPI {
//
// // expects a Tachiyomi legacy backup json as a file upload, the file must be named "backup.json"
// app.post("/api/v1/backup/legacy/import/file") { ctx ->
// ctx.result(
// ctx.future(
// JavalinSetup.future {
// restoreLegacyBackup(ctx.uploadedFile("backup.json")!!.content)
// }
@@ -326,7 +326,7 @@ object AnimeAPI {
// // returns a Tachiyomi legacy backup json created from the current database as a json body
// app.get("/api/v1/backup/legacy/export") { ctx ->
// ctx.contentType("application/json")
// ctx.result(
// ctx.future(
// JavalinSetup.future {
// createLegacyBackup(
// BackupFlags(
@@ -348,7 +348,7 @@ object AnimeAPI {
// val currentDate = sdf.format(Date())
//
// ctx.header("Content-Disposition", "attachment; filename=\"tachidesk_$currentDate.json\"")
// ctx.result(
// ctx.future(
// JavalinSetup.future {
// createLegacyBackup(
// BackupFlags(
@@ -23,8 +23,8 @@ import suwayomi.tachidesk.anime.model.table.AnimeStatus
import suwayomi.tachidesk.anime.model.table.AnimeTable
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.clearCachedImage
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getCachedImageResponse
import suwayomi.tachidesk.server.ApplicationDirs
import java.io.InputStream
@@ -40,7 +40,7 @@ import suwayomi.tachidesk.anime.impl.util.PackageTools.trustedSignatures
import suwayomi.tachidesk.anime.model.table.AnimeExtensionTable
import suwayomi.tachidesk.anime.model.table.AnimeSourceTable
import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getCachedImageResponse
import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy
import java.io.File
@@ -82,7 +82,7 @@ object PackageTools {
)
handler.dump(errorFile, emptyArray<String>())
} else {
BytecodeEditor.fixAndroidClasses(jarFilePath.toFile())
BytecodeEditor.fixAndroidClasses(jarFilePath)
}
}
@@ -19,65 +19,67 @@ import suwayomi.tachidesk.manga.controller.DownloadController
import suwayomi.tachidesk.manga.controller.ExtensionController
import suwayomi.tachidesk.manga.controller.MangaController
import suwayomi.tachidesk.manga.controller.SourceController
import suwayomi.tachidesk.manga.controller.UpdateController
object MangaAPI {
fun defineEndpoints() {
path("extension") {
get("list", ExtensionController::list)
get("install/:pkgName", ExtensionController::install)
get("install/{pkgName}", ExtensionController::install)
post("install", ExtensionController::installFile)
get("update/:pkgName", ExtensionController::update)
get("uninstall/:pkgName", ExtensionController::uninstall)
get("update/{pkgName}", ExtensionController::update)
get("uninstall/{pkgName}", ExtensionController::uninstall)
get("icon/:apkName", ExtensionController::icon)
get("icon/{apkName}", ExtensionController::icon)
}
path("source") {
get("list", SourceController::list)
get(":sourceId", SourceController::retrieve)
get("{sourceId}", SourceController::retrieve)
get(":sourceId/popular/:pageNum", SourceController::popular)
get(":sourceId/latest/:pageNum", SourceController::latest)
get("{sourceId}/popular/{pageNum}", SourceController::popular)
get("{sourceId}/latest/{pageNum}", SourceController::latest)
get(":sourceId/preferences", SourceController::getPreferences)
post(":sourceId/preferences", SourceController::setPreference)
get("{sourceId}/preferences", SourceController::getPreferences)
post("{sourceId}/preferences", SourceController::setPreference)
get(":sourceId/filters", SourceController::filters)
get("{sourceId}/filters", SourceController::filters)
get(":sourceId/search/:searchTerm/:pageNum", SourceController::searchSingle)
// get("search/:searchTerm/:pageNum", SourceController::searchGlobal)
get("{sourceId}/search/{searchTerm}/{pageNum}", SourceController::searchSingle)
// get("search/{searchTerm}/{pageNum}", SourceController::searchGlobal)
}
path("manga") {
get(":mangaId", MangaController::retrieve)
get(":mangaId/thumbnail", MangaController::thumbnail)
get("{mangaId}", MangaController::retrieve)
get("{mangaId}/thumbnail", MangaController::thumbnail)
get(":mangaId/category", MangaController::categoryList)
get(":mangaId/category/:categoryId", MangaController::addToCategory)
delete(":mangaId/category/:categoryId", MangaController::removeFromCategory)
get("{mangaId}/category", MangaController::categoryList)
get("{mangaId}/category/{categoryId}", MangaController::addToCategory)
delete("{mangaId}/category/{categoryId}", MangaController::removeFromCategory)
get(":mangaId/library", MangaController::addToLibrary)
delete(":mangaId/library", MangaController::removeFromLibrary)
get("{mangaId}/library", MangaController::addToLibrary)
delete("{mangaId}/library", MangaController::removeFromLibrary)
patch(":mangaId/meta", MangaController::meta)
patch("{mangaId}/meta", MangaController::meta)
get(":mangaId/chapters", MangaController::chapterList)
get(":mangaId/chapter/:chapterIndex", MangaController::chapterRetrieve)
patch(":mangaId/chapter/:chapterIndex", MangaController::chapterModify)
get("{mangaId}/chapters", MangaController::chapterList)
get("{mangaId}/chapter/{chapterIndex}", MangaController::chapterRetrieve)
patch("{mangaId}/chapter/{chapterIndex}", MangaController::chapterModify)
delete("{mangaId}/chapter/{chapterIndex}", MangaController::chapterDelete)
patch(":mangaId/chapter/:chapterIndex/meta", MangaController::chapterMeta)
patch("{mangaId}/chapter/{chapterIndex}/meta", MangaController::chapterMeta)
get(":mangaId/chapter/:chapterIndex/page/:index", MangaController::pageRetrieve)
get("{mangaId}/chapter/{chapterIndex}/page/{index}", MangaController::pageRetrieve)
}
path("category") {
get("", CategoryController::categoryList)
post("", CategoryController::categoryCreate)
get(":categoryId", CategoryController::categoryMangas)
patch(":categoryId", CategoryController::categoryModify)
delete(":categoryId", CategoryController::categoryDelete)
get("{categoryId}", CategoryController::categoryMangas)
patch("{categoryId}", CategoryController::categoryModify)
delete("{categoryId}", CategoryController::categoryDelete)
patch("reorder", CategoryController::categoryReorder)
}
@@ -102,8 +104,12 @@ object MangaAPI {
}
path("download") {
get(":mangaId/chapter/:chapterIndex", DownloadController::queueChapter)
delete(":mangaId/chapter/:chapterIndex", DownloadController::unqueueChapter)
get("{mangaId}/chapter/{chapterIndex}", DownloadController::queueChapter)
delete("{mangaId}/chapter/{chapterIndex}", DownloadController::unqueueChapter)
}
path("update") {
get("recentChapters", UpdateController::recentChapters)
}
}
}
@@ -5,7 +5,7 @@ import suwayomi.tachidesk.manga.impl.backup.BackupFlags
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupExport
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupImport
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator
import suwayomi.tachidesk.server.JavalinSetup
import suwayomi.tachidesk.server.JavalinSetup.future
import java.text.SimpleDateFormat
import java.util.Date
@@ -20,8 +20,8 @@ object BackupController {
/** expects a Tachiyomi protobuf backup in the body */
fun protobufImport(ctx: Context) {
ctx.json(
JavalinSetup.future {
ctx.future(
future {
ProtoBackupImport.performRestore(ctx.bodyAsInputStream())
}
)
@@ -30,8 +30,8 @@ object BackupController {
/** expects a Tachiyomi protobuf backup as a file upload, the file must be named "backup.proto.gz" */
fun protobufImportFile(ctx: Context) {
// TODO: rewrite this with ctx.uploadedFiles(), don't call the multipart field "backup.proto.gz"
ctx.json(
JavalinSetup.future {
ctx.future(
future {
ProtoBackupImport.performRestore(ctx.uploadedFile("backup.proto.gz")!!.content)
}
)
@@ -40,8 +40,8 @@ object BackupController {
/** returns a Tachiyomi protobuf backup created from the current database as a body */
fun protobufExport(ctx: Context) {
ctx.contentType("application/octet-stream")
ctx.result(
JavalinSetup.future {
ctx.future(
future {
ProtoBackupExport.createBackup(
BackupFlags(
includeManga = true,
@@ -61,8 +61,8 @@ object BackupController {
val currentDate = SimpleDateFormat("yyyy-MM-dd_HH-mm").format(Date())
ctx.header("Content-Disposition", """attachment; filename="tachidesk_$currentDate.proto.gz"""")
ctx.result(
JavalinSetup.future {
ctx.future(
future {
ProtoBackupExport.createBackup(
BackupFlags(
includeManga = true,
@@ -78,8 +78,8 @@ object BackupController {
/** Reports missing sources and trackers, expects a Tachiyomi protobuf backup in the body */
fun protobufValidate(ctx: Context) {
ctx.json(
JavalinSetup.future {
ctx.future(
future {
ProtoBackupValidator.validate(ctx.bodyAsInputStream())
}
)
@@ -87,8 +87,8 @@ object BackupController {
/** Reports missing sources and trackers, expects a Tachiyomi protobuf backup as a file upload, the file must be named "backup.proto.gz" */
fun protobufValidateFile(ctx: Context) {
ctx.json(
JavalinSetup.future {
ctx.future(
future {
ProtoBackupValidator.validate(ctx.uploadedFile("backup.proto.gz")!!.content)
}
)
@@ -8,12 +8,12 @@ package suwayomi.tachidesk.manga.controller
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import io.javalin.http.Context
import io.javalin.websocket.WsHandler
import io.javalin.websocket.WsConfig
import suwayomi.tachidesk.manga.impl.download.DownloadManager
object DownloadController {
/** Download queue stats */
fun downloadsWS(ws: WsHandler) {
fun downloadsWS(ws: WsConfig) {
ws.onConnect { ctx ->
DownloadManager.addClient(ctx)
DownloadManager.notifyClient(ctx)
@@ -18,7 +18,7 @@ object ExtensionController {
/** list all extensions */
fun list(ctx: Context) {
ctx.json(
ctx.future(
future {
ExtensionsList.getExtensionList()
}
@@ -29,7 +29,7 @@ object ExtensionController {
fun install(ctx: Context) {
val pkgName = ctx.pathParam("pkgName")
ctx.json(
ctx.future(
future {
Extension.installExtension(pkgName)
}
@@ -42,7 +42,7 @@ object ExtensionController {
val uploadedFile = ctx.uploadedFile("file")!!
logger.debug { "Uploaded extension file name: " + uploadedFile.filename }
ctx.json(
ctx.future(
future {
Extension.installExternalExtension(uploadedFile.content, uploadedFile.filename)
}
@@ -53,7 +53,7 @@ object ExtensionController {
fun update(ctx: Context) {
val pkgName = ctx.pathParam("pkgName")
ctx.json(
ctx.future(
future {
Extension.updateExtension(pkgName)
}
@@ -71,9 +71,10 @@ object ExtensionController {
/** icon for extension named `apkName` */
fun icon(ctx: Context) {
val apkName = ctx.pathParam("apkName")
val useCache = ctx.queryParam("useCache")?.toBoolean() ?: true
ctx.result(
future { Extension.getExtensionIcon(apkName) }
ctx.future(
future { Extension.getExtensionIcon(apkName, useCache) }
.thenApply {
ctx.header("content-type", it.second)
it.first
@@ -19,9 +19,9 @@ object MangaController {
/** get manga info */
fun retrieve(ctx: Context) {
val mangaId = ctx.pathParam("mangaId").toInt()
val onlineFetch = ctx.queryParam("onlineFetch", "false").toBoolean()
val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean() ?: false
ctx.json(
ctx.future(
future {
Manga.getManga(mangaId, onlineFetch)
}
@@ -31,9 +31,10 @@ object MangaController {
/** manga thumbnail */
fun thumbnail(ctx: Context) {
val mangaId = ctx.pathParam("mangaId").toInt()
val useCache = ctx.queryParam("useCache")?.toBoolean() ?: true
ctx.result(
future { Manga.getMangaThumbnail(mangaId) }
ctx.future(
future { Manga.getMangaThumbnail(mangaId, useCache) }
.thenApply {
ctx.header("content-type", it.second)
it.first
@@ -45,7 +46,7 @@ object MangaController {
fun addToLibrary(ctx: Context) {
val mangaId = ctx.pathParam("mangaId").toInt()
ctx.result(
ctx.future(
future { Library.addMangaToLibrary(mangaId) }
)
}
@@ -54,7 +55,7 @@ object MangaController {
fun removeFromLibrary(ctx: Context) {
val mangaId = ctx.pathParam("mangaId").toInt()
ctx.result(
ctx.future(
future { Library.removeMangaFromLibrary(mangaId) }
)
}
@@ -97,16 +98,16 @@ object MangaController {
fun chapterList(ctx: Context) {
val mangaId = ctx.pathParam("mangaId").toInt()
val onlineFetch = ctx.queryParam("onlineFetch", "false").toBoolean()
val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean() ?: false
ctx.json(future { Chapter.getChapterList(mangaId, onlineFetch) })
ctx.future(future { Chapter.getChapterList(mangaId, onlineFetch) })
}
/** used to display a chapter, get a chapter in order to show its pages */
fun chapterRetrieve(ctx: Context) {
val chapterIndex = ctx.pathParam("chapterIndex").toInt()
val mangaId = ctx.pathParam("mangaId").toInt()
ctx.json(future { Chapter.getChapter(chapterIndex, mangaId) })
ctx.future(future { Chapter.getChapter(chapterIndex, mangaId) })
}
/** used to modify a chapter's parameters */
@@ -124,6 +125,16 @@ object MangaController {
ctx.status(200)
}
/** delete a downloaded chapter */
fun chapterDelete(ctx: Context) {
val chapterIndex = ctx.pathParam("chapterIndex").toInt()
val mangaId = ctx.pathParam("mangaId").toInt()
Chapter.deleteChapter(mangaId, chapterIndex)
ctx.status(200)
}
/** used to modify a chapter's meta parameters */
fun chapterMeta(ctx: Context) {
val chapterIndex = ctx.pathParam("chapterIndex").toInt()
@@ -142,9 +153,10 @@ object MangaController {
val mangaId = ctx.pathParam("mangaId").toInt()
val chapterIndex = ctx.pathParam("chapterIndex").toInt()
val index = ctx.pathParam("index").toInt()
val useCache = ctx.queryParam("useCache")?.toBoolean() ?: true
ctx.result(
future { Page.getPageImage(mangaId, chapterIndex, index) }
ctx.future(
future { Page.getPageImage(mangaId, chapterIndex, index, useCache) }
.thenApply {
ctx.header("content-type", it.second)
it.first
@@ -30,7 +30,7 @@ object SourceController {
fun popular(ctx: Context) {
val sourceId = ctx.pathParam("sourceId").toLong()
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(
ctx.future(
future {
MangaList.getMangaList(sourceId, pageNum, popular = true)
}
@@ -41,7 +41,7 @@ object SourceController {
fun latest(ctx: Context) {
val sourceId = ctx.pathParam("sourceId").toLong()
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(
ctx.future(
future {
MangaList.getMangaList(sourceId, pageNum, popular = false)
}
@@ -64,7 +64,7 @@ object SourceController {
/** fetch filters of source with id `sourceId` */
fun filters(ctx: Context) {
val sourceId = ctx.pathParam("sourceId").toLong()
val reset = ctx.queryParam("reset", "false").toBoolean()
val reset = ctx.queryParam("reset")?.toBoolean() ?: false
ctx.json(Search.getInitialFilterList(sourceId, reset))
}
@@ -74,7 +74,7 @@ object SourceController {
val sourceId = ctx.pathParam("sourceId").toLong()
val searchTerm = ctx.pathParam("searchTerm")
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.json(future { Search.sourceSearch(sourceId, searchTerm, pageNum) })
ctx.future(future { Search.sourceSearch(sourceId, searchTerm, pageNum) })
}
/** all source search */
@@ -0,0 +1,23 @@
package suwayomi.tachidesk.manga.controller
import io.javalin.http.Context
import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.server.JavalinSetup.future
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
object UpdateController {
/** get recently updated manga chapters */
fun recentChapters(ctx: Context) {
ctx.future(
future {
Chapter.getRecentChapters()
}
)
}
}
@@ -9,8 +9,9 @@ package suwayomi.tachidesk.manga.impl
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.chapter.ChapterRecognition
import org.jetbrains.exposed.dao.id.EntityID
import org.jetbrains.exposed.sql.SortOrder.DESC
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
@@ -18,14 +19,19 @@ import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.Manga.getManga
import suwayomi.tachidesk.manga.impl.Page.getPageName
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
import suwayomi.tachidesk.manga.impl.util.getChapterDir
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.PageTable
import suwayomi.tachidesk.manga.model.table.toDataClass
import java.io.File
import java.time.Instant
object Chapter {
@@ -35,7 +41,7 @@ object Chapter {
getSourceChapters(mangaId)
} else {
transaction {
ChapterTable.select { ChapterTable.manga eq mangaId }.orderBy(ChapterTable.chapterIndex to DESC)
ChapterTable.select { ChapterTable.manga eq mangaId }.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
.map {
ChapterTable.toDataClass(it)
}
@@ -48,14 +54,22 @@ object Chapter {
private suspend fun getSourceChapters(mangaId: Int): List<ChapterDataClass> {
val manga = getManga(mangaId)
val source = getHttpSource(manga.sourceId.toLong())
val chapterList = source.fetchChapterList(
SManga.create().apply {
title = manga.title
url = manga.url
}
).awaitSingle()
val sManga = SManga.create().apply {
title = manga.title
url = manga.url
}
val chapterList = source.fetchChapterList(sManga).awaitSingle()
// Recognize number for new chapters.
chapterList.forEach {
source.prepareNewChapter(it, sManga)
ChapterRecognition.parseChapterNumber(it, sManga)
}
val chapterCount = chapterList.count()
var now = Instant.now().epochSecond
transaction {
chapterList.reversed().forEachIndexed { index, fetchedChapter ->
@@ -68,7 +82,8 @@ object Chapter {
it[chapter_number] = fetchedChapter.chapter_number
it[scanlator] = fetchedChapter.scanlator
it[chapterIndex] = index + 1
it[sourceOrder] = index + 1
it[fetchedAt] = now++
it[ChapterTable.manga] = mangaId
}
} else {
@@ -78,7 +93,7 @@ object Chapter {
it[chapter_number] = fetchedChapter.chapter_number
it[scanlator] = fetchedChapter.scanlator
it[chapterIndex] = index + 1
it[sourceOrder] = index + 1
it[ChapterTable.manga] = mangaId
}
}
@@ -91,8 +106,8 @@ object Chapter {
val dbChapterList = transaction { ChapterTable.select { ChapterTable.manga eq mangaId }.toList() }
dbChapterList.forEach {
if (it[ChapterTable.chapterIndex] >= chapterList.size ||
chapterList[it[ChapterTable.chapterIndex] - 1].url != it[ChapterTable.url]
if (it[ChapterTable.sourceOrder] >= chapterList.size ||
chapterList[it[ChapterTable.sourceOrder] - 1].url != it[ChapterTable.url]
) {
transaction {
PageTable.deleteWhere { PageTable.chapter eq it[ChapterTable.id] }
@@ -125,6 +140,7 @@ object Chapter {
dbChapter[ChapterTable.lastReadAt],
chapterCount - index,
dbChapter[ChapterTable.fetchedAt],
dbChapter[ChapterTable.isDownloaded],
dbChapter[ChapterTable.pageCount],
@@ -139,11 +155,19 @@ object Chapter {
suspend fun getChapter(chapterIndex: Int, mangaId: Int): ChapterDataClass {
val chapterEntry = transaction {
ChapterTable.select {
(ChapterTable.chapterIndex eq chapterIndex) and (ChapterTable.manga eq mangaId)
(ChapterTable.sourceOrder eq chapterIndex) and (ChapterTable.manga eq mangaId)
}.first()
}
return if (!chapterEntry[ChapterTable.isDownloaded]) {
val isReallyDownloaded =
chapterEntry[ChapterTable.isDownloaded] && firstPageExists(mangaId, chapterEntry[ChapterTable.id].value)
return if (!isReallyDownloaded) {
transaction {
ChapterTable.update({ (ChapterTable.sourceOrder eq chapterIndex) and (ChapterTable.manga eq mangaId) }) {
it[isDownloaded] = false
}
}
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
@@ -183,7 +207,7 @@ object Chapter {
val pageCount = pageList.count()
transaction {
ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) }) {
ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }) {
it[ChapterTable.pageCount] = pageCount
}
}
@@ -199,7 +223,8 @@ object Chapter {
chapterEntry[ChapterTable.lastPageRead],
chapterEntry[ChapterTable.lastReadAt],
chapterEntry[ChapterTable.chapterIndex],
chapterEntry[ChapterTable.sourceOrder],
chapterEntry[ChapterTable.fetchedAt],
chapterEntry[ChapterTable.isDownloaded],
pageCount,
chapterCount.toInt(),
@@ -210,6 +235,15 @@ object Chapter {
}
}
private fun firstPageExists(mangaId: Int, chapterId: Int): Boolean {
val chapterDir = getChapterDir(mangaId, chapterId)
return ImageResponse.findFileNameStartingWith(
chapterDir,
getPageName(0, chapterDir)
) != null
}
fun modifyChapter(
mangaId: Int,
chapterIndex: Int,
@@ -220,7 +254,7 @@ object Chapter {
) {
transaction {
if (listOf(isRead, isBookmarked, lastPageRead).any { it != null }) {
ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) }) { update ->
ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }) { update ->
isRead?.also {
update[ChapterTable.isRead] = it
}
@@ -235,7 +269,7 @@ object Chapter {
}
markPrevRead?.let {
ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex less chapterIndex) }) {
ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder less chapterIndex) }) {
it[ChapterTable.isRead] = markPrevRead
}
}
@@ -251,16 +285,16 @@ object Chapter {
fun modifyChapterMeta(mangaId: Int, chapterIndex: Int, key: String, value: String) {
transaction {
val chapter =
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) }
.first()[ChapterTable.id]
val chapterId =
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }
.first()[ChapterTable.id].value
val meta =
transaction { ChapterMetaTable.select { (ChapterMetaTable.ref eq chapter) and (ChapterMetaTable.key eq key) } }.firstOrNull()
transaction { ChapterMetaTable.select { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) } }.firstOrNull()
if (meta == null) {
ChapterMetaTable.insert {
it[ChapterMetaTable.key] = key
it[ChapterMetaTable.value] = value
it[ChapterMetaTable.ref] = chapter
it[ChapterMetaTable.ref] = chapterId
}
} else {
ChapterMetaTable.update {
@@ -269,4 +303,34 @@ object Chapter {
}
}
}
fun deleteChapter(mangaId: Int, chapterIndex: Int) {
transaction {
val chapterId =
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }
.first()[ChapterTable.id].value
val chapterDir = getChapterDir(mangaId, chapterId)
File(chapterDir).deleteRecursively()
ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }) {
it[isDownloaded] = false
}
}
}
fun getRecentChapters(): List<MangaChapterDataClass> {
return transaction {
(ChapterTable innerJoin MangaTable)
.select { (MangaTable.inLibrary eq true) and (ChapterTable.fetchedAt greater MangaTable.inLibraryAt) }
.orderBy(ChapterTable.fetchedAt to SortOrder.DESC)
.map {
MangaChapterDataClass(
MangaTable.toDataClass(it),
ChapterTable.toDataClass(it)
)
}
}
}
}
@@ -16,6 +16,7 @@ import suwayomi.tachidesk.manga.impl.Manga.getManga
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import java.time.Instant
object Library {
suspend fun addMangaToLibrary(mangaId: Int) {
@@ -25,8 +26,9 @@ object Library {
val defaultCategories = CategoryTable.select { CategoryTable.isDefault eq true }.toList()
MangaTable.update({ MangaTable.id eq manga.id }) {
it[MangaTable.inLibrary] = true
it[MangaTable.defaultCategory] = defaultCategories.isEmpty()
it[inLibrary] = true
it[inLibraryAt] = Instant.now().epochSecond
it[defaultCategory] = defaultCategories.isEmpty()
}
defaultCategories.forEach { category ->
@@ -22,8 +22,9 @@ import suwayomi.tachidesk.manga.impl.Source.getSource
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.clearCachedImage
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
import suwayomi.tachidesk.manga.impl.util.updateMangaDownloadDir
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
@@ -60,6 +61,7 @@ object Manga {
mangaEntry[MangaTable.genre].toGenreList(),
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
mangaEntry[MangaTable.inLibrary],
mangaEntry[MangaTable.inLibraryAt],
getSource(mangaEntry[MangaTable.sourceReference]),
getMangaMetaMap(mangaId),
mangaEntry[MangaTable.realUrl],
@@ -76,6 +78,12 @@ object Manga {
transaction {
MangaTable.update({ MangaTable.id eq mangaId }) {
if (fetchedManga.title != mangaEntry[MangaTable.title]) {
val canUpdateTitle = updateMangaDownloadDir(mangaId, fetchedManga.title)
if (canUpdateTitle)
it[MangaTable.title] = fetchedManga.title
}
it[MangaTable.initialized] = true
it[MangaTable.artist] = fetchedManga.artist
@@ -86,7 +94,11 @@ object Manga {
if (fetchedManga.thumbnail_url != null && fetchedManga.thumbnail_url.orEmpty().isNotEmpty())
it[MangaTable.thumbnail_url] = fetchedManga.thumbnail_url
it[MangaTable.realUrl] = try { source.mangaDetailsRequest(sManga).url.toString() } catch (e: Exception) { null }
it[MangaTable.realUrl] = try {
source.mangaDetailsRequest(sManga).url.toString()
} catch (e: Exception) {
null
}
}
}
@@ -110,6 +122,7 @@ object Manga {
fetchedManga.genre.toGenreList(),
MangaStatus.valueOf(fetchedManga.status).name,
mangaEntry[MangaTable.inLibrary],
mangaEntry[MangaTable.inLibraryAt],
getSource(mangaEntry[MangaTable.sourceReference]),
getMangaMetaMap(mangaId),
mangaEntry[MangaTable.realUrl],
@@ -146,19 +159,25 @@ object Manga {
}
private val applicationDirs by DI.global.instance<ApplicationDirs>()
suspend fun getMangaThumbnail(mangaId: Int): Pair<InputStream, String> {
suspend fun getMangaThumbnail(mangaId: Int, useCache: Boolean): Pair<InputStream, String> {
val saveDir = applicationDirs.mangaThumbnailsRoot
val fileName = mangaId.toString()
return getCachedImageResponse(saveDir, fileName) {
getManga(mangaId) // make sure is initialized
return getImageResponse(saveDir, fileName, useCache) {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val sourceId = mangaEntry[MangaTable.sourceReference]
val source = getHttpSource(sourceId)
val thumbnailUrl = mangaEntry[MangaTable.thumbnail_url]!!
val thumbnailUrl = mangaEntry[MangaTable.thumbnail_url]
?: if (!mangaEntry[MangaTable.initialized]) {
// initialize then try again
getManga(mangaId)
transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }[MangaTable.thumbnail_url]!!
} else {
// source provides no thumbnail url for this manga
throw NullPointerException()
}
source.client.newCall(
GET(thumbnailUrl, source.headers)
@@ -81,6 +81,7 @@ object MangaList {
manga.genre.toGenreList(),
MangaStatus.valueOf(manga.status).name,
false, // It's a new manga entry
0,
meta = getMangaMetaMap(mangaId),
realUrl = mangaEntry[MangaTable.realUrl],
freshData = true
@@ -103,6 +104,7 @@ object MangaList {
mangaEntry[MangaTable.genre].toGenreList(),
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
mangaEntry[MangaTable.inLibrary],
mangaEntry[MangaTable.inLibraryAt],
meta = getMangaMetaMap(mangaId),
realUrl = mangaEntry[MangaTable.realUrl],
freshData = false
@@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.impl
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.local.LocalSource
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import org.jetbrains.exposed.sql.and
@@ -17,9 +18,10 @@ import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
import suwayomi.tachidesk.manga.impl.util.getChapterDir
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.SafePath
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.PageTable
@@ -39,26 +41,41 @@ object Page {
return page.imageUrl!!
}
suspend fun getPageImage(mangaId: Int, chapterIndex: Int, index: Int): Pair<InputStream, String> {
suspend fun getPageImage(mangaId: Int, chapterIndex: Int, index: Int, useCache: Boolean = true): Pair<InputStream, String> {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
val chapterEntry = transaction {
ChapterTable.select {
(ChapterTable.chapterIndex eq chapterIndex) and (ChapterTable.manga eq mangaId)
(ChapterTable.sourceOrder eq chapterIndex) and (ChapterTable.manga eq mangaId)
}.first()
}
val chapterId = chapterEntry[ChapterTable.id].value
val pageEntry = transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq index) }.first() }
val pageEntry =
transaction { PageTable.select { (PageTable.chapter eq chapterId) and (PageTable.index eq index) }.first() }
val tachiPage = Page(
val tachiyomiPage = Page(
pageEntry[PageTable.index],
pageEntry[PageTable.url],
pageEntry[PageTable.imageUrl]
)
// we treat Local source differently
if (mangaEntry[MangaTable.sourceReference] == LocalSource.ID) {
// is of archive format
if (LocalSource.pageCache.containsKey(chapterEntry[ChapterTable.url])) {
val pageStream = LocalSource.pageCache[chapterEntry[ChapterTable.url]]!![index]()
return pageStream to "image/jpeg"
}
// is of directory format
return ImageResponse.getNoCacheImageResponse {
source.fetchImage(tachiyomiPage).awaitSingle()
}
}
if (pageEntry[PageTable.imageUrl] == null) {
val trueImageUrl = getTrueImageUrl(tachiPage, source)
val trueImageUrl = getTrueImageUrl(tachiyomiPage, source)
transaction {
PageTable.update({ (PageTable.chapter eq chapterId) and (PageTable.index eq index) }) {
it[imageUrl] = trueImageUrl
@@ -66,30 +83,28 @@ object Page {
}
}
val saveDir = getChapterDir(mangaId, chapterId)
File(saveDir).mkdirs()
val fileName = String.format("%03d", index) // e.g. 001.jpeg
val chapterDir = getChapterDir(mangaId, chapterId)
File(chapterDir).mkdirs()
val fileName = getPageName(index, chapterDir) // e.g. 001
return getCachedImageResponse(saveDir, fileName) {
source.fetchImage(tachiPage).awaitSingle()
return getImageResponse(chapterDir, fileName, useCache) {
source.fetchImage(tachiyomiPage).awaitSingle()
}
}
private val applicationDirs by DI.global.instance<ApplicationDirs>()
private fun getChapterDir(mangaId: Int, chapterId: Int): String {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val source = getHttpSource(mangaEntry[MangaTable.sourceReference])
val chapterEntry = transaction { ChapterTable.select { ChapterTable.id eq chapterId }.first() }
// TODO(v0.6.0) : zero based pages are deprecated
fun getPageName(index: Int, chapterDir: String): String {
val zeroBasedPageExists = ImageResponse.findFileNameStartingWith(
chapterDir,
formatPageName(0)
) != null
val sourceDir = source.toString()
val mangaDir = SafePath.buildValidFilename(mangaEntry[MangaTable.title])
val chapterDir = SafePath.buildValidFilename(
when {
chapterEntry[ChapterTable.scanlator] != null -> "${chapterEntry[ChapterTable.scanlator]}_${chapterEntry[ChapterTable.name]}"
else -> chapterEntry[ChapterTable.name]
}
)
if (zeroBasedPageExists) return formatPageName(index)
return "${applicationDirs.mangaRoot}/$sourceDir/$mangaDir/$chapterDir"
return formatPageName(index + 1)
}
private fun formatPageName(index: Int) = String.format("%03d", index)
private val applicationDirs by DI.global.instance<ApplicationDirs>()
}
@@ -12,6 +12,7 @@ import android.content.Context
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.getPreferenceKey
import eu.kanade.tachiyomi.source.local.LocalSource
import mu.KotlinLogging
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
@@ -45,7 +46,8 @@ object Source {
getExtensionIconUrl(sourceExtension[ExtensionTable.apkName]),
httpSource.supportsLatest,
httpSource is ConfigurableSource,
it[SourceTable.isNsfw]
it[SourceTable.isNsfw],
httpSource.toString(),
)
}
}
@@ -53,6 +55,11 @@ object Source {
fun getSource(sourceId: Long): SourceDataClass { // all the data extracted fresh form the source instance
return transaction {
if (sourceId == LocalSource.ID) {
// initialize local source
getHttpSource(sourceId)
}
val source = SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()
val httpSource = source?.let { getHttpSource(sourceId) }
val extension = source?.let {
@@ -70,7 +77,8 @@ object Source {
},
httpSource?.supportsLatest,
httpSource?.let { it is ConfigurableSource },
source?.get(SourceTable.isNsfw)
source?.get(SourceTable.isNsfw),
httpSource?.toString()
)
}
}
@@ -160,7 +160,7 @@ object ProtoBackupImport : ProtoBackupBase() {
it[chapter_number] = chapter.chapter_number
it[scanlator] = chapter.scanlator
it[chapterIndex] = chaptersLength - chapter.source_order
it[sourceOrder] = chaptersLength - chapter.source_order
it[ChapterTable.manga] = mangaId
it[isRead] = chapter.read
@@ -207,7 +207,7 @@ object ProtoBackupImport : ProtoBackupBase() {
it[chapter_number] = chapter.chapter_number
it[scanlator] = chapter.scanlator
it[chapterIndex] = chaptersLength - chapter.source_order
it[sourceOrder] = chaptersLength - chapter.source_order
it[ChapterTable.manga] = mangaId
it[isRead] = chapter.read
@@ -13,4 +13,4 @@ data class BrokenBackupHistory(
data class BackupHistory(
@ProtoNumber(1) var url: String,
@ProtoNumber(2) var lastRead: Long
)
)
@@ -77,7 +77,7 @@ object DownloadManager {
mangaId,
chapter = ChapterTable.toDataClass(
transaction {
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) }
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }
.first()
}
)
@@ -61,7 +61,7 @@ class Downloader(private val downloadQueue: CopyOnWriteArrayList<DownloadChapter
}
download.state = Finished
transaction {
ChapterTable.update({ (ChapterTable.manga eq download.mangaId) and (ChapterTable.chapterIndex eq download.chapterIndex) }) {
ChapterTable.update({ (ChapterTable.manga eq download.mangaId) and (ChapterTable.sourceOrder eq download.chapterIndex) }) {
it[isDownloaded] = true
}
}
@@ -37,10 +37,9 @@ import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_NSFW
import suwayomi.tachidesk.manga.impl.util.PackageTools.METADATA_SOURCE_CLASS
import suwayomi.tachidesk.manga.impl.util.PackageTools.dex2jar
import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo
import suwayomi.tachidesk.manga.impl.util.PackageTools.getSignatureHash
import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources
import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.storage.CachedImageResponse.getCachedImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.server.ApplicationDirs
@@ -122,12 +121,12 @@ object Extension {
)
}
val signatureHash = getSignatureHash(packageInfo)
// TODO: allow trusting keys
// val signatureHash = getSignatureHash(packageInfo)
// if (signatureHash == null) {
// throw Exception("Package $pkgName isn't signed")
// } else if (signatureHash !in trustedSignatures) {
// // TODO: allow trusting keys
// throw Exception("This apk is not a signed with the official tachiyomi signature")
// }
@@ -268,13 +267,13 @@ object Extension {
return installExtension(pkgName)
}
suspend fun getExtensionIcon(apkName: String): Pair<InputStream, String> {
val iconUrl =
transaction { ExtensionTable.select { ExtensionTable.apkName eq apkName }.first() }[ExtensionTable.iconUrl]
suspend fun getExtensionIcon(apkName: String, useCache: Boolean): Pair<InputStream, String> {
val iconUrl = if (apkName == "localSource") ""
else transaction { ExtensionTable.select { ExtensionTable.apkName eq apkName }.first() }[ExtensionTable.iconUrl]
val saveDir = "${applicationDirs.extensionsRoot}/icon"
return getCachedImageResponse(saveDir, apkName) {
return getImageResponse(saveDir, apkName, useCache) {
network.client.newCall(
GET(iconUrl)
).await()
@@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.impl.extension
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.local.LocalSource
import mu.KotlinLogging
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
@@ -46,7 +47,7 @@ object ExtensionsList {
}
fun extensionTableAsDataClass() = transaction {
ExtensionTable.selectAll().map {
ExtensionTable.selectAll().filter { it[ExtensionTable.name] != LocalSource.EXTENSION_NAME }.map {
ExtensionDataClass(
it[ExtensionTable.apkName],
getExtensionIconUrl(it[ExtensionTable.apkName]),
@@ -15,15 +15,10 @@ import org.objectweb.asm.FieldVisitor
import org.objectweb.asm.Handle
import org.objectweb.asm.MethodVisitor
import org.objectweb.asm.Opcodes
import org.objectweb.asm.tree.ClassNode
import suwayomi.tachidesk.manga.impl.util.storage.use
import java.io.File
import java.io.IOException
import java.util.jar.JarEntry
import java.util.jar.JarFile
import java.util.jar.JarOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.nio.file.FileSystems
import java.nio.file.Files
import java.nio.file.Path
import kotlin.streams.asSequence
object BytecodeEditor {
private val logger = KotlinLogging.logger {}
@@ -33,77 +28,52 @@ object BytecodeEditor {
*
* @param jarFile The JarFile to replace class references in
*/
fun fixAndroidClasses(jarFile: File) {
val nodes = loadClasses(jarFile)
.mapValues { (className, classFileBuffer) ->
logger.trace { "Processing class $className" }
transform(classFileBuffer)
} + loadNonClasses(jarFile)
saveAsJar(nodes, jarFile)
}
/**
* Load all classes inside the [jar] [File]
*
* @param jar The JarFile to load classes from
*
* @return [Map] with class names and [ByteArray]s of bytecode
*/
private fun loadClasses(jar: File): Map<String, ByteArray> {
return JarFile(jar).use { jarFile ->
jarFile.entries()
.asSequence()
.mapNotNull {
readJar(jarFile, it)
}
.toMap()
fun fixAndroidClasses(jarFile: Path) {
FileSystems.newFileSystem(jarFile, null as ClassLoader?)?.use {
Files.walk(it.getPath("/")).asSequence()
.filterNotNull()
.filterNot(Files::isDirectory)
.mapNotNull(::getClassBytes)
.map(::transform)
.forEach(::write)
}
}
/**
* Get class file in [jar] for [entry]
* Get class bytes from a [Path]
*
* @param jar The jar to get the class from
* @param entry The entry in the jar
* @param path The path entry to get the class bytes from
*
* @return [Pair] of the class name plus the class [ByteArray], or null if it's not a valid class
* @return [Pair] of the [Path] plus the class [ByteArray], or null if it's not a valid class
*/
private fun readJar(jar: JarFile, entry: JarEntry): Pair<String, ByteArray>? {
private fun getClassBytes(path: Path): Pair<Path, ByteArray>? {
return try {
jar.getInputStream(entry).use { stream ->
if (entry.name.endsWith(".class")) {
val bytes = stream.readBytes()
if (bytes.size < 4) {
// Invalid class size
return@use null
}
val cafebabe = String.format(
"%02X%02X%02X%02X",
bytes[0],
bytes[1],
bytes[2],
bytes[3]
)
if (cafebabe.lowercase() != "cafebabe") {
// Corrupted class
return@use null
}
if (path.toString().endsWith(".class")) {
val bytes = Files.readAllBytes(path)
if (bytes.size < 4) {
// Invalid class size
return null
}
val cafebabe = String.format(
"%02X%02X%02X%02X",
bytes[0],
bytes[1],
bytes[2],
bytes[3]
)
if (cafebabe.lowercase() != "cafebabe") {
// Corrupted class
return null
}
getNode(bytes).name to bytes
} else null
}
} catch (e: IOException) {
logger.error(e) { "Error loading jar file" }
path to bytes
} else null
} catch (e: Exception) {
logger.error(e) { "Error loading class from Path: $path" }
null
}
}
private fun getNode(bytes: ByteArray): ClassNode {
val cr = ClassReader(bytes)
return ClassNode().also { cr.accept(it, ClassReader.EXPAND_FRAMES) }
}
/**
* The path where replacement classes will reside
*/
@@ -153,9 +123,9 @@ object BytecodeEditor {
*
* @return [ByteArray] with modified bytecode
*/
private fun transform(classfileBuffer: ByteArray): ByteArray {
private fun transform(pair: Pair<Path, ByteArray>): Pair<Path, ByteArray> {
// Read the class and prepare to modify it
val cr = ClassReader(classfileBuffer)
val cr = ClassReader(pair.second)
val cw = ClassWriter(cr, 0)
// Modify the class
cr.accept(
@@ -277,51 +247,10 @@ object BytecodeEditor {
},
0
)
return cw.toByteArray()
return pair.first to cw.toByteArray()
}
/**
* Load non-class files from the jar, such as icons and the manifest
*
* @param [jarFile] The file to load resources from
*
* @return [Map] of resources
*/
private fun loadNonClasses(jarFile: File): Map<String, ByteArray> {
val entries = mutableMapOf<String, ByteArray>()
ZipInputStream(jarFile.inputStream()).use { stream ->
var nextEntry: ZipEntry?
while (stream.nextEntry.also { nextEntry = it } != null) {
nextEntry?.use(stream) { entry ->
// If it ends with class or is a directory ignore it
if (!entry.name.endsWith(".class") && !entry.isDirectory) {
val bytes = stream.readBytes()
entries[entry.name] = bytes
}
}
}
}
return entries
}
/**
* Save jar with modified content
*
* @param outBytes [Map] of names and [ByteArray]s of content to save inside the jar
* @param file JarFile to save to
*/
private fun saveAsJar(outBytes: Map<String, ByteArray>, file: File) {
JarOutputStream(file.outputStream()).use { out ->
outBytes.forEach { (entry, value) ->
// Append extension to class entries
out.putNextEntry(
ZipEntry(
entry + if (entry.contains(".")) "" else ".class"
)
)
out.write(value)
out.closeEntry()
}
}
private fun write(pair: Pair<Path, ByteArray>) {
Files.write(pair.first, pair.second)
}
}
@@ -0,0 +1,65 @@
package suwayomi.tachidesk.manga.impl.util
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.manga.impl.util.storage.SafePath
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.server.ApplicationDirs
import java.io.File
private val applicationDirs by DI.global.instance<ApplicationDirs>()
fun getMangaDir(mangaId: Int): String {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val source = GetHttpSource.getHttpSource(mangaEntry[MangaTable.sourceReference])
val sourceDir = source.toString()
val mangaDir = SafePath.buildValidFilename(mangaEntry[MangaTable.title])
return "${applicationDirs.mangaRoot}/$sourceDir/$mangaDir"
}
fun getChapterDir(mangaId: Int, chapterId: Int): String {
val chapterEntry = transaction { ChapterTable.select { ChapterTable.id eq chapterId }.first() }
val chapterDir = SafePath.buildValidFilename(
when {
chapterEntry[ChapterTable.scanlator] != null -> "${chapterEntry[ChapterTable.scanlator]}_${chapterEntry[ChapterTable.name]}"
else -> chapterEntry[ChapterTable.name]
}
)
return getMangaDir(mangaId) + "/$chapterDir"
}
/** return value says if rename/move was successful */
fun updateMangaDownloadDir(mangaId: Int, newTitle: String): Boolean {
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val source = GetHttpSource.getHttpSource(mangaEntry[MangaTable.sourceReference])
val sourceDir = source.toString()
val mangaDir = SafePath.buildValidFilename(mangaEntry[MangaTable.title])
val newMangaDir = SafePath.buildValidFilename(newTitle)
val oldDir = "${applicationDirs.mangaRoot}/$sourceDir/$mangaDir"
val newDir = "${applicationDirs.mangaRoot}/$sourceDir/$newMangaDir"
val oldDirFile = File(oldDir)
val newDirFile = File(newDir)
return if (oldDirFile.exists())
oldDirFile.renameTo(newDirFile)
else true
}
@@ -9,6 +9,7 @@ package suwayomi.tachidesk.manga.impl.util
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
import eu.kanade.tachiyomi.source.local.LocalSource
import eu.kanade.tachiyomi.source.online.HttpSource
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
@@ -35,6 +36,10 @@ object GetHttpSource {
SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()!!
}
if (sourceId == LocalSource.ID) {
return LocalSource()
}
val extensionId = sourceRecord[SourceTable.extension]
val extensionRecord = transaction {
ExtensionTable.select { ExtensionTable.id eq extensionId }.first()
@@ -82,7 +82,7 @@ object PackageTools {
)
handler.dump(errorFile, emptyArray<String>())
} else {
BytecodeEditor.fixAndroidClasses(jarFilePath.toFile())
BytecodeEditor.fixAndroidClasses(jarFilePath)
}
}
@@ -13,12 +13,12 @@ import java.io.File
import java.io.FileInputStream
import java.io.InputStream
object CachedImageResponse {
object ImageResponse {
private fun pathToInputStream(path: String): InputStream {
return FileInputStream(path).buffered()
}
private fun findFileNameStartingWith(directoryPath: String, fileName: String): String? {
fun findFileNameStartingWith(directoryPath: String, fileName: String): String? {
val target = "$fileName."
File(directoryPath).listFiles().orEmpty().forEach { file ->
if (file.name.startsWith(target))
@@ -68,4 +68,30 @@ object CachedImageResponse {
File(it).delete()
}
}
suspend fun getNoCacheImageResponse(fetcher: suspend () -> Response): Pair<InputStream, String> {
val response = fetcher()
if (response.code == 200) {
val responseBytes = response.body!!.bytes()
// find image type
val imageType = response.headers["content-type"]
?: ImageUtil.findImageType { responseBytes.inputStream() }?.mime
?: "image/jpeg"
return responseBytes.inputStream() to imageType
} else {
response.closeQuietly()
throw Exception("request error! ${response.code}")
}
}
suspend fun getImageResponse(saveDir: String, fileName: String, useCache: Boolean = false, fetcher: suspend () -> Response): Pair<InputStream, String> {
return if (useCache) {
getCachedImageResponse(saveDir, fileName, fetcher)
} else {
getNoCacheImageResponse(fetcher)
}
}
}
@@ -1,11 +1,5 @@
package suwayomi.tachidesk.manga.impl.util.storage
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.GIF
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.JPG
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.PNG
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.WEBP
import java.io.InputStream
/*
* Copyright (C) Contributors to the Suwayomi project
*
@@ -13,8 +7,23 @@ import java.io.InputStream
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
// adopted from: https://github.com/tachiyomiorg/tachiyomi/blob/ff369010074b058bb734ce24c66508300e6e9ac6/app/src/main/java/eu/kanade/tachiyomi/util/system/ImageUtil.kt
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.GIF
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.JPG
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.PNG
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil.ImageType.WEBP
import java.io.InputStream
import java.net.URLConnection
// adopted from: eu.kanade.tachiyomi.util.system.ImageUtil
object ImageUtil {
fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
val contentType = try {
URLConnection.guessContentTypeFromName(name)
} catch (e: Exception) {
null
} ?: openStream?.let { findImageType(it)?.mime }
return contentType?.startsWith("image/") ?: false
}
fun findImageType(openStream: () -> InputStream): ImageType? {
return openStream().use { findImageType(it) }
@@ -27,9 +27,13 @@ data class ChapterDataClass(
/** last read page, zero means not read/no data */
val lastReadAt: Long,
// TODO(v0.6.0): rename to sourceOrder
/** this chapter's index, starts with 1 */
val index: Int,
/** the date we fist saw this chapter*/
val fetchedAt: Long,
/** is chapter downloaded */
val downloaded: Boolean,
@@ -0,0 +1,13 @@
package suwayomi.tachidesk.manga.model.dataclass
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
data class MangaChapterDataClass(
val manga: MangaDataClass,
val chapter: ChapterDataClass,
)
@@ -26,6 +26,7 @@ data class MangaDataClass(
val genre: List<String> = emptyList(),
val status: String = MangaStatus.UNKNOWN.name,
val inLibrary: Boolean = false,
val inLibraryAt: Long = 0,
val source: SourceDataClass? = null,
/** meta data for clients */
@@ -23,4 +23,7 @@ data class SourceDataClass(
/** The Source class has a @Nsfw annotation */
val isNsfw: Boolean?,
/** A nicer version of [name] */
val displayName: String?,
)
@@ -25,9 +25,9 @@ object ChapterTable : IntIdTable() {
val isBookmarked = bool("bookmark").default(false)
val lastPageRead = integer("last_page_read").default(0)
val lastReadAt = long("last_read_at").default(0)
val fetchedAt = long("fetched_at").default(0)
// index is reserved by a function
val chapterIndex = integer("index")
val sourceOrder = integer("source_order")
val isDownloaded = bool("is_downloaded").default(false)
@@ -48,7 +48,8 @@ fun ChapterTable.toDataClass(chapterEntry: ResultRow) =
chapterEntry[isBookmarked],
chapterEntry[lastPageRead],
chapterEntry[lastReadAt],
chapterEntry[chapterIndex],
chapterEntry[sourceOrder],
chapterEntry[fetchedAt],
chapterEntry[isDownloaded],
chapterEntry[pageCount],
transaction { ChapterTable.select { manga eq chapterEntry[manga].value }.count().toInt() },
@@ -20,7 +20,7 @@ object ExtensionTable : IntIdTable() {
val pkgName = varchar("pkg_name", 128)
val versionName = varchar("version_name", 16)
val versionCode = integer("version_code")
val lang = varchar("lang", 10)
val lang = varchar("lang", 32)
val isNsfw = bool("is_nsfw")
val isInstalled = bool("is_installed").default(false)
@@ -21,16 +21,17 @@ object MangaTable : IntIdTable() {
val title = varchar("title", 512)
val initialized = bool("initialized").default(false)
val artist = varchar("artist", 64).nullable()
val author = varchar("author", 64).nullable()
val description = varchar("description", 4096).nullable()
val genre = varchar("genre", 1024).nullable()
val artist = varchar("artist", 512).nullable()
val author = varchar("author", 512).nullable()
val description = varchar("description", Integer.MAX_VALUE).nullable()
val genre = varchar("genre", Integer.MAX_VALUE).nullable()
val status = integer("status").default(SManga.UNKNOWN)
val thumbnail_url = varchar("thumbnail_url", 2048).nullable()
val inLibrary = bool("in_library").default(false)
val defaultCategory = bool("default_category").default(true)
val inLibraryAt = long("in_library_at").default(0)
// the [source] field name is used by some ancestor of IntIdTable
val sourceReference = long("source")
@@ -56,6 +57,7 @@ fun MangaTable.toDataClass(mangaEntry: ResultRow) =
mangaEntry[genre].toGenreList(),
Companion.valueOf(mangaEntry[status]).name,
mangaEntry[inLibrary],
mangaEntry[inLibraryAt],
meta = getMangaMetaMap(mangaEntry[id].value),
realUrl = mangaEntry[realUrl],
)
@@ -12,7 +12,7 @@ import org.jetbrains.exposed.dao.id.IdTable
object SourceTable : IdTable<Long>() {
override val id = long("id").entityId()
val name = varchar("name", 128)
val lang = varchar("lang", 10)
val lang = varchar("lang", 32)
val extension = reference("extension", ExtensionTable)
val isNsfw = bool("is_nsfw").default(false)
}
@@ -9,6 +9,7 @@ package suwayomi.tachidesk.server
import io.javalin.Javalin
import io.javalin.apibuilder.ApiBuilder.path
import io.javalin.core.security.RouteRole
import io.javalin.http.staticfiles.Location
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -78,6 +79,18 @@ object JavalinSetup {
ctx.result(e.message ?: "Internal Server Error")
}
app.before { ctx ->
fun credentialsValid(): Boolean {
val (username, password) = ctx.basicAuthCredentials()
return username == serverConfig.basicAuthUsername && password == serverConfig.basicAuthPassword
}
if (serverConfig.basicAuthEnabled && !(ctx.basicAuthCredentialsExist() && credentialsValid())) {
ctx.header("WWW-Authenticate", "Basic")
ctx.status(401).json("Unauthorized")
}
}
app.routes {
path("api/v1/") {
GlobalAPI.defineEndpoints()
@@ -86,4 +99,8 @@ object JavalinSetup {
}
}
}
object Auth {
enum class Role : RouteRole { ANYONE, USER_READ, USER_WRITE }
}
}
@@ -33,6 +33,11 @@ class ServerConfig(config: Config, moduleName: String = MODULE_NAME) : SystemPro
val webUIInterface: String by overridableConfig
val electronPath: String by overridableConfig
// Authentication
val basicAuthEnabled: Boolean by overridableConfig
val basicAuthUsername: String by overridableConfig
val basicAuthPassword: String by overridableConfig
companion object {
fun register(config: Config) = ServerConfig(config.getConfig(MODULE_NAME))
}
@@ -8,6 +8,7 @@ package suwayomi.tachidesk.server
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.App
import eu.kanade.tachiyomi.source.local.LocalSource
import mu.KotlinLogging
import org.kodein.di.DI
import org.kodein.di.bind
@@ -33,6 +34,7 @@ class ApplicationDirs(
val mangaThumbnailsRoot = "$dataRoot/manga-thumbnails"
val animeThumbnailsRoot = "$dataRoot/anime-thumbnails"
val mangaRoot = "$dataRoot/manga"
val localMangaRoot = "$dataRoot/manga-local"
val webUIRoot = "$dataRoot/webUI"
}
@@ -63,6 +65,8 @@ fun applicationSetup() {
applicationDirs.extensionsRoot + "/icon",
applicationDirs.mangaThumbnailsRoot,
applicationDirs.animeThumbnailsRoot,
applicationDirs.mangaRoot,
applicationDirs.localMangaRoot,
).forEach {
File(it).mkdirs()
}
@@ -93,7 +97,21 @@ fun applicationSetup() {
}
}
} catch (e: Exception) {
logger.error("Exception while creating initial server.conf:\n", e)
logger.error("Exception while creating initial server.conf", e)
}
// copy local source icon
try {
val localSourceIconFile = File("${applicationDirs.extensionsRoot}/icon/localSource.png")
if (!localSourceIconFile.exists()) {
JavalinSetup::class.java.getResourceAsStream("/icon/localSource.png").use { input ->
localSourceIconFile.outputStream().use { output ->
input.copyTo(output)
}
}
}
} catch (e: Exception) {
logger.error("Exception while copying Local source's icon", e)
}
// fixes #119 , ref: https://github.com/Suwayomi/Tachidesk-Server/issues/119#issuecomment-894681292 , source Id calculation depends on String.lowercase()
@@ -101,6 +119,8 @@ fun applicationSetup() {
databaseUp()
LocalSource.addDbRecords()
// create system tray
if (serverConfig.systemTrayEnabled) {
try {
@@ -0,0 +1,20 @@
package suwayomi.tachidesk.server.database.migration
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import de.neonew.exposed.migrations.helpers.SQLMigration
@Suppress("ClassName", "unused")
class M0014_MangaRemoveLengthLimit : SQLMigration() {
override val sql = """
ALTER TABLE MANGA ALTER COLUMN ARTIST VARCHAR(512);
ALTER TABLE MANGA ALTER COLUMN AUTHOR VARCHAR(512);
ALTER TABLE MANGA ALTER COLUMN DESCRIPTION VARCHAR; -- the default length is `Integer.MAX_VALUE`
ALTER TABLE MANGA ALTER COLUMN GENRE VARCHAR; -- the default length is `Integer.MAX_VALUE`
""".trimIndent()
}
@@ -0,0 +1,18 @@
package suwayomi.tachidesk.server.database.migration
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import de.neonew.exposed.migrations.helpers.SQLMigration
@Suppress("ClassName", "unused")
class M0015_SourceAndExtensionLangAddLengthLimit : SQLMigration() {
override val sql = """
ALTER TABLE SOURCE ALTER COLUMN LANG VARCHAR(32);
ALTER TABLE EXTENSION ALTER COLUMN LANG VARCHAR(32);
""".trimIndent()
}
@@ -0,0 +1,17 @@
package suwayomi.tachidesk.server.database.migration
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import de.neonew.exposed.migrations.helpers.SQLMigration
@Suppress("ClassName", "unused")
class M0016_ChapterIndexRenameToSourceOrder : SQLMigration() {
override val sql = """
ALTER TABLE CHAPTER ALTER COLUMN INDEX RENAME TO SOURCE_ORDER;
""".trimIndent()
}
@@ -0,0 +1,18 @@
package suwayomi.tachidesk.server.database.migration
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import de.neonew.exposed.migrations.helpers.AddColumnMigration
@Suppress("ClassName", "unused")
class M0017_ChapterFetchedAt : AddColumnMigration(
"Chapter",
"fetched_at",
"BIGINT",
"0"
)
@@ -0,0 +1,18 @@
package suwayomi.tachidesk.server.database.migration
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import de.neonew.exposed.migrations.helpers.AddColumnMigration
@Suppress("ClassName", "unused")
class M0018_MangaInLibraryAt : AddColumnMigration(
"Manga",
"in_library_at",
"BIGINT",
"0"
)
@@ -46,7 +46,7 @@ object AppMutex {
}
return try {
JavalinJackson.fromJson(response, AboutDataClass::class.java)
JavalinJackson().fromJsonString(response, AboutDataClass::class.java)
AppMutexState.TachideskInstanceRunning
} catch (e: IOException) {
AppMutexState.OtherApplicationRunning
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -16,3 +16,8 @@ server.webUIEnabled = true
server.initialOpenInBrowserEnabled = true
server.webUIInterface = "browser" # "browser" or "electron"
server.electronPath = ""
# Authentication
server.basicAuthEnabled = false
server.basicAuthUsername = ""
server.basicAuthPassword = ""