Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 819ceba17d | |||
| 0aa0d62e03 | |||
| b3e2a35880 | |||
| 15ec20c65d | |||
| d4d6d7e12f | |||
| 2e7a4f1421 | |||
| ab8a52faf3 | |||
| bd465559fb | |||
| 13ec45a95c | |||
| 13b034875b | |||
| bb701fb088 | |||
| b367414865 | |||
| 4b00eec608 | |||
| 5e11b51152 | |||
| 9fb43b996e | |||
| bc2072e81f | |||
| f36bc3f643 | |||
| f7901ad843 | |||
| 3771030ed6 | |||
| 57197e58b5 | |||
| ac601399ac | |||
| 6a0e221153 | |||
| 6a949fc851 | |||
| f1a077dc2f | |||
| f20962b02b | |||
| 77e057f244 | |||
| 2c5114c770 | |||
| a30895a199 | |||
| 6d46d4b3da | |||
| 006efbbb77 | |||
| 52334087ad | |||
| ea8fb2c70a | |||
| 531d148718 | |||
| d83ddea323 | |||
| b5dea34090 | |||
| 7d8e3202b5 | |||
| d956e0af4b | |||
| 9ad70990b5 | |||
| 82d711f077 | |||
| 5fc28ef711 | |||
| 1f3dc682e2 | |||
| cce7768246 | |||
| 01172b0664 | |||
| 1ca11fdd34 | |||
| 52a064ae45 | |||
| 6c62ddf927 | |||
| 47e04c08d0 | |||
| d1601874b4 | |||
| 6bc19af041 | |||
| 4e72a3886f | |||
| 8e6b219eea | |||
| bd638251e4 | |||
| 0173d5e4b3 | |||
| 4f364e134b | |||
| 94d2519717 | |||
| 6a11d2e357 | |||
| 0a9e0bc9e4 | |||
| 5914e367d1 | |||
| aeaed888d4 | |||
| a0f054b005 | |||
| 4498e9d444 | |||
| 43e0763fef | |||
| a519c8a482 | |||
| 19bc595a2a | |||
| db07825c58 | |||
| b199e3bf0e | |||
| 1f9ea0891e | |||
| 0a7aa48f1e | |||
| 4b65b7da6c | |||
| 183a7dac4b | |||
| 6726f008c1 | |||
| 89cf0c140f | |||
| 504025ce80 | |||
| fee9e914f1 | |||
| 76efa71c68 | |||
| 26e61959ae | |||
| 9a8956ef9d | |||
| 10d3ffc2f6 | |||
| 090399f61d | |||
| ae7d975a92 | |||
| 55ec6bcafe | |||
| f0566d15af | |||
| a730b692bc | |||
| 826d767423 | |||
| 6d227c7fcd | |||
| 7d9d97840e | |||
| 110ded45a0 | |||
| 7872b593c7 | |||
| 90be30bddb | |||
| a298c61dab | |||
| eb416c45bd | |||
| b05b817aeb | |||
| 666602283a | |||
| ac040a4bae | |||
| b4982c8f22 | |||
| ce3ad92095 | |||
| 8e1ac8698c | |||
| b60a39c7cb | |||
| 3b3e8ba4c8 | |||
| e387f6d3be | |||
| 799d469cb6 | |||
| a54a596fa7 | |||
| 92d73d0285 | |||
| acb752e4e8 | |||
| 9e377abba6 | |||
| 04552c0923 | |||
| 2e1fb85b73 | |||
| 50db32d9b4 | |||
| 28743d953e | |||
| b109d26aa7 | |||
| 7fd21bd06d | |||
| 12e0ffb466 | |||
| 9259341df8 | |||
| 8e8aca7e7b | |||
| 7f0bcd987b | |||
| ef21de95cb | |||
| ca3246de02 |
@@ -78,11 +78,14 @@ jobs:
|
||||
echo "$genTag"
|
||||
echo "::set-output name=value::$genTag"
|
||||
|
||||
- name: make windows packages
|
||||
- name: make bundle packages
|
||||
run: |
|
||||
cd master/scripts
|
||||
./windows-bundler.sh win32
|
||||
./windows-bundler.sh win64
|
||||
./unix-bundler.sh linux-x64
|
||||
./unix-bundler.sh macOS-x64
|
||||
./unix-bundler.sh macOS-arm64
|
||||
|
||||
# - name: Mock make windows packages
|
||||
# run: |
|
||||
@@ -116,11 +119,7 @@ jobs:
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
token: ${{ secrets.DEPLOY_PREVIEW_TOKEN }}
|
||||
artifacts: "master/server/build/*.jar,master/server/build/*.zip"
|
||||
artifacts: "master/server/build/*.jar,master/server/build/*.zip,master/server/build/*.tar.gz"
|
||||
owner: "Suwayomi"
|
||||
repo: "Tachidesk-Server-preview"
|
||||
tag: ${{ steps.GenTagName.outputs.value }}
|
||||
|
||||
- name: Run Docker build workflow
|
||||
run: |
|
||||
curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.DEPLOY_PREVIEW_TOKEN }}" -d '{"ref":"main", "inputs":{"tachidesk_release_type": "preview"}}' https://api.github.com/repos/suwayomi/docker-tachidesk/actions/workflows/build_container_images.yml/dispatches
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
name: Docker Build Stable
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build_publish_docker_container:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: run docker build and publish script
|
||||
run: |
|
||||
curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.DEPLOY_PREVIEW_TOKEN }}" -d '{"ref":"main", "inputs":{"tachidesk_release_type": "stable"}}' https://api.github.com/repos/suwayomi/docker-tachidesk/actions/workflows/build_container_images.yml/dispatches
|
||||
|
||||
@@ -51,13 +51,6 @@ jobs:
|
||||
cd master
|
||||
curl https://raw.githubusercontent.com/Suwayomi/Tachidesk/android-jar/android.jar -o AndroidCompat/lib/android.jar
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: |
|
||||
**/webUI/node_modules
|
||||
key: ${{ runner.os }}-${{ hashFiles('**/webUI/yarn.lock') }}
|
||||
|
||||
- name: Build and copy webUI, Build Jar
|
||||
uses: eskatos/gradle-command-action@v1
|
||||
env:
|
||||
@@ -70,24 +63,21 @@ jobs:
|
||||
dependencies-cache-enabled: true
|
||||
configuration-cache-enabled: true
|
||||
|
||||
- name: make windows packages
|
||||
- name: make bundle packages
|
||||
run: |
|
||||
cd master/scripts
|
||||
./windows-bundler.sh win32
|
||||
./windows-bundler.sh win64
|
||||
./unix-bundler.sh linux-x64
|
||||
./unix-bundler.sh macOS-x64
|
||||
./unix-bundler.sh macOS-arm64
|
||||
|
||||
- name: Upload Release
|
||||
uses: xresloader/upload-to-github-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
file: "master/server/build/*.jar;master/server/build/*.zip"
|
||||
file: "master/server/build/*.jar;master/server/build/*.zip;master/server/build/*.tar.gz"
|
||||
tags: true
|
||||
draft: true
|
||||
verbose: true
|
||||
|
||||
- name: Run Docker build workflow
|
||||
run: |
|
||||
sleep 10 # sleep a bit to make sure the release is actually inside github db
|
||||
curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token ${{ secrets.DEPLOY_PREVIEW_TOKEN }}" -d '{"ref":"main", "inputs":{"tachidesk_release_type": "stable"}}' https://api.github.com/repos/suwayomi/docker-tachidesk/actions/workflows/build_container_images.yml/dispatches
|
||||
|
||||
|
||||
+3
-2
@@ -11,6 +11,7 @@ server/tmp/
|
||||
server/tachiserver-data/
|
||||
|
||||
# bundle asset downlaods
|
||||
OpenJDK*.zip
|
||||
electron-*.zip
|
||||
OpenJDK*.*
|
||||
zulu*jre*
|
||||
electron-*.*
|
||||
rcedit-*
|
||||
|
||||
@@ -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!")
|
||||
|
||||
@@ -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!"); }
|
||||
|
||||
+1
-1
@@ -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>()
|
||||
|
||||
+2
-2
@@ -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?>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Server: v0.X.Y-rXXX + WebUI: rXXX
|
||||
## TL;DR
|
||||
<!-- TODO: fill before release -->
|
||||
|
||||
## Tachidesk-Server Changelog
|
||||
- N/A
|
||||
|
||||
|
||||
## Tachidesk-WebUI Changelog
|
||||
- N/A
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
# 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
|
||||
- WebUI now comes with an updated Material Design looks and is faster a little bit.
|
||||
- WebUI now shows Nsfw content by default, disable it in settings if you prefer to not see Nsfw stuff
|
||||
- Added support for configuration of sources, this enables MangaDex, Komga, Cubari and many other sources
|
||||
- Chapters in the Manga page and Sources in the source page now look nicer and will glow with mouse hover
|
||||
|
||||
## Tachidesk-Server
|
||||
### Public API
|
||||
#### Non-breaking changes
|
||||
- (r888) add installing APK from external sources endpoint
|
||||
|
||||
#### Breaking changes
|
||||
- (r877 [#188](https://github.com/Suwayomi/Tachidesk-Server/pull/188) by @Syer10) `MangaDataClass.genre` changed type to `List<String>`
|
||||
|
||||
#### Bug fixes
|
||||
- (r899-r901) fix when an external apk is installed and it doesn't have the default tachiyomi-extensions name
|
||||
- (r905) fix a bug where if two sources return the same URL, a false duplicate might be detected
|
||||
|
||||
### Private API
|
||||
- (r887) the `run` task won't call `downloadWebUI` now
|
||||
- (r902) cleanup print/ln instances
|
||||
- (r906) better handling of uninstalling Extensions
|
||||
|
||||
## Tachidesk-WebUI
|
||||
#### Visible changes
|
||||
- (r770) add support for the new genre type
|
||||
- (r771) set the default value of `showNsfw` to `true` so we won't have visual artifacts with a clean install
|
||||
- (r774 [#21](https://github.com/Suwayomi/Tachidesk-WebUI/pull/21) by @voltrare) `ReaderNavbar.jsx`: Swap close and retract Navbar buttons
|
||||
- (r775 [#23](https://github.com/Suwayomi/Tachidesk-WebUI/pull/23) by @voltrare) `yarn.lock`: Fixes version inconsistency after commit 9b866811b
|
||||
- (r776 [#23](https://github.com/Suwayomi/Tachidesk-WebUI/pull/23) by @voltrare) add margin between Source and Extension cards, make the Search button look nicer
|
||||
- (r777) add support for installing external APK files
|
||||
- (r778) fix the makeToaster?
|
||||
- (r779) Action button for installing external extension
|
||||
- (r780 Suwayomi/Tachidesk-WebUI#25) add on hover, active effect to Chapter/Episode card
|
||||
- (r782-r785) updating material-ui to v5 changed the theme
|
||||
- (r785-r788) better `SourceCard` looks on mobile, move `SourceDataClass.isConfigurable` gear button to `SourceMangas`
|
||||
- (r789) implement source configuration
|
||||
|
||||
#### Bug fixes
|
||||
- N/A
|
||||
|
||||
#### Internal changes
|
||||
- (r782-r785) update dependencies, migrate material-ui from v4 to v5
|
||||
|
||||
|
||||
|
||||
# Server: v0.4.9 + WebUI: r769
|
||||
## Tachidesk-Server
|
||||
### Public API
|
||||
#### Non-breaking changes
|
||||
- N/A
|
||||
|
||||
#### Breaking changes
|
||||
- (r857) renamed: `SourceDataClass.isNSFW` -> `SourceDataClass.isNsfw`
|
||||
|
||||
#### Bug fixes
|
||||
- N/A
|
||||
|
||||
### Private API
|
||||
- (r850) Bump WebUI version to r767
|
||||
- (r861) Bump WebUI version to r769
|
||||
|
||||
#### Non-code changes
|
||||
- (r851) Add this changelog file and `CHANGELOG-TEMPLATE.md`
|
||||
- (r852-r853) `CONTRIBUTING.md`: Add a note about this maintaining this file changelog
|
||||
- (r855) `CONTRIBUTING.md`: text cleanup
|
||||
- (r859) `CONTRIBUTING.md`: remove dumb rule
|
||||
- (r862) `windows-bundler.sh`: update jre
|
||||
- (r864) add linux and macOS bundler script and launcher scripts
|
||||
- (r865) fix macOS bundler script and launcher scripts
|
||||
- (r866) bump electron version to v14.0.0
|
||||
- (r868) add linux and macOS bundlers to the publish workflow
|
||||
- (r871) `publish.yml`: remove node module cache, won't need it anymore
|
||||
- (r873) `publish.yml` and `build_push.yml`: fix oopsies
|
||||
|
||||
|
||||
## Tachidesk-WebUI
|
||||
#### Visible changes
|
||||
- (r767-r769) Support for hiding NSFW content in settings screen, extensions screen, sources screen
|
||||
|
||||
#### Bug fixes
|
||||
- N/A
|
||||
|
||||
#### Internal changes
|
||||
- (r767) Remove some duplicate dependency declaration from `package.json`
|
||||
|
||||
#### Non-code changes
|
||||
- (r42-r45) Change `README.md`: some links and stuff
|
||||
- (r45-r765) Add all of the commit history from when WebUI was separated from Server, jumping from r45 to r765 (r45 is exactly the same as r765)
|
||||
- (r766) Steal `.gitattributes` from Tachidesk-Server
|
||||
- (r767) Dependency cleanup in `package.json`
|
||||
|
||||
|
||||
|
||||
|
||||
# Server: v0.4.8 + WebUI: r41
|
||||
## Tachidesk-Server
|
||||
### Public API
|
||||
#### Non-breaking changes
|
||||
- Added support for serializing Search Filters
|
||||
- `SourceDataClass` now has a `isNsfw` key
|
||||
|
||||
#### Breaking changes
|
||||
- N/A
|
||||
|
||||
#### Bug fixes
|
||||
- Fixed a bug where backup restore reversed chapter order
|
||||
- Open Site feature now works properly (https://github.com/Suwayomi/Tachidesk-WebUI/issues/19)
|
||||
|
||||
### Private API
|
||||
- Added `CloudflareInterceptor` from TachiWeb-Server
|
||||
- Restoring backup for mangas in library(merging manga data) is now supported
|
||||
|
||||
## Tachidesk-WebUI
|
||||
#### Visible changes
|
||||
- Better looking manga card titles
|
||||
- Better reader title, next, prev buttons
|
||||
|
||||
#### Bug fixes
|
||||
- Open Site feature now works properly (https://github.com/Suwayomi/Tachidesk-WebUI/issues/19)
|
||||
- Re-ordering categories now works
|
||||
|
||||
#### Internal changes
|
||||
- N/A
|
||||
+8
-6
@@ -2,18 +2,20 @@
|
||||
## Where should I start?
|
||||
Checkout [This Kanban Board](https://github.com/Suwayomi/Tachidesk/projects/1) to see the rough development roadmap.
|
||||
|
||||
**Note to potential contributors:** Notify the developers on [Suwayomi discord](https://discord.gg/DDZdqZWaHA) (#programming channel) or open a WIP pull request before starting if you decide to take on working on anything from/not from the roadmap in order to avoid parallel efforts on the same issue/feature.
|
||||
**Note 1:** Notify the developers on [Suwayomi discord](https://discord.gg/DDZdqZWaHA) (#programming channel) or open a WIP pull request before starting if you decide to take on working on anything from/not from the roadmap in order to avoid parallel efforts on the same issue/feature.
|
||||
|
||||
**Note 2:** Store all changes with each direct commit/PR in [CHANGELOG.md](./CHANGELOG.md).
|
||||
|
||||
## How does Tachidesk-Server work?
|
||||
This project has two components:
|
||||
1. **Server:** contains the implementation of [tachiyomi's extensions library](https://github.com/tachiyomiorg/extensions-lib) and uses an Android compatibility library to run jar libraries converted from apk extensions. All this concludes to serving a REST API.
|
||||
2. **WebUI:** A react SPA(`create-react-app`) project that works with the server to do the presentation located at https://github.com/Suwayomi/Tachidesk-WebUI
|
||||
2. **WebUI:** A React SPA(`create-react-app`) project that works with the server to do the presentation located at https://github.com/Suwayomi/Tachidesk-WebUI
|
||||
|
||||
## Why a web server app?
|
||||
## Why a web app?
|
||||
This structure is chosen to
|
||||
- Achieve the maximum multi-platform-ness
|
||||
- Gives the ability to acces Tachidesk-Server from a remote client e.g. your phone, tablet or smart TV
|
||||
- Eaise development of user intefaces for Tachidesk
|
||||
- Gives the ability to access Tachidesk-Server from a remote client e.g., your phone, tablet or smart TV
|
||||
- Ease development of user interfaces for Tachidesk
|
||||
|
||||
## Building from source
|
||||
### Prerequisites
|
||||
@@ -22,7 +24,7 @@ You need these software packages installed in order to build the project
|
||||
- Java Development Kit and Java Runtime Environment version 8 or newer(both Oracle JDK and OpenJDK works)
|
||||
- Android stubs jar
|
||||
- **Manual download:** Download [android.jar](https://raw.githubusercontent.com/Suwayomi/Tachidesk/android-jar/android.jar) and put it under `AndroidCompat/lib`.
|
||||
- **Automated download:** Run `AndroidCompat/getAndroid.sh`(MacOS/Linux) or `AndroidCompat/getAndroid.ps1`(Windows) from project's root directory to download and rebuild the jar file from Google's repository.
|
||||
- **Automated download:** Run `AndroidCompat/getAndroid.sh`(macOS/Linux) or `AndroidCompat/getAndroid.ps1`(Windows) from project's root directory to download and rebuild the jar file from Google's repository.
|
||||
|
||||
### building the full-blown jar (Tachidesk-Server + Tachidesk-WebUI bundle)
|
||||
Run `./gradlew server:downloadWebUI server:shadowJar`, the resulting built jar file will be `server/build/Tachidesk-Server-vX.Y.Z-rxxx.jar`.
|
||||
|
||||
@@ -3,16 +3,7 @@
|
||||
|-------|----------|---------|---------|
|
||||
|  | [](https://github.com/Suwayomi/Tachidesk/releases) | [](https://github.com/Suwayomi/Tachidesk-preview/releases/latest) | [](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 [Tachodesk-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 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:
|
||||
|
||||
@@ -41,23 +41,45 @@ Here is a list of current features:
|
||||
|
||||
**Note:** Tachidesk-Server is alpha software and can break rarely and/or with each update. See [Troubleshooting](https://github.com/Suwayomi/Tachidesk-Server/wiki/Troubleshooting) if it happens.
|
||||
|
||||
## Downloading and Running the app
|
||||
### All Operating Systems
|
||||
You should have The Java Runtime Environment(JRE) 8 or newer and a modern browser installed(Google is your friend for seeking assitance). Also an internet connection is required as almost everything this app does is downloading stuff.
|
||||
# Downloading and Running the app
|
||||
## General Requirements
|
||||
In order to use the app effectively you need the following:
|
||||
- The jar release of Tachideesk-Server
|
||||
- The Java Runtime Environment(JRE) 8 or newer (included in bundle releases)
|
||||
- A Modern Browser like Google Chrome, Firefox, etc.
|
||||
- ElectronJS (optional) (included in bundle releases)
|
||||
- An internet connection (when you want to use online features)
|
||||
## Using the jar release directly
|
||||
Download the latest `.jar` release from [the releases section](https://github.com/Suwayomi/Tachidesk-Server/releases) or a preview jar build from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
|
||||
|
||||
Download the latest "Stable" jar release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview jar build from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
|
||||
Make sure you have The Java Runtime Environment installed on your system, Double click on the jar file or run `java -jar Tachidesk-vX.Y.Z-rxxx.jar` (or `java -jar Tachidesk-latest.jar` if you have the latest preview) from a Terminal/Command Prompt window to run the app which will open a new browser window automatically. Also the System Tray Icon is your friend if you need to open the browser window again or close Tachidesk.
|
||||
|
||||
Double click on the jar file or run `java -jar Tachidesk-vX.Y.Z-rxxx.jar` (or `java -jar Tachidesk-latest.jar` if you have the latest preview) from a Terminal/Command Prompt window to run the app which will open a new browser window automatically. Also the System Tray Icon is your friend if you need to open the browser window again or close Tachidesk.
|
||||
## Using Operating System Specific Bundles
|
||||
To facilitate the use of Tachidesk we provide bundle releases that include The Java Runtime Environment, ElectronJS and 3 Tachidesk Launcher Scripts.
|
||||
|
||||
#### Launcher Scripts
|
||||
- `Tachidesk Electron Launcher`: Launches Tachidesk inside Electron as a desktop applicaton
|
||||
- `Tachidesk Browser Launcher`: Launches Tachidesk in a browser window
|
||||
- `Tachidesk Debug Launcher`: Launches Tachidesk with debug logs attached. If Tachidesk doesn't work for you, running this can give you insight into why.
|
||||
|
||||
**Node:** Linux launcher scripts are named a bit differently but work the same.
|
||||
|
||||
### Windows
|
||||
Download the latest "Stable" win32 or win64 (depending on your system, usually you want win64) release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview one from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
|
||||
Download the latest `win32`(Windows 32-bit) or `win64`(Windows 64-bit) release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview one from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
|
||||
|
||||
The Windows specific build has java bundled inside, so you don't have to install java to use it. Unzip `Tachidesk-vX.Y.Z-rxxx-win64.zip` and run one of the Launcher files depending on what you want(see bellow). The rest works like the previous section.
|
||||
#### Windows Launchers
|
||||
- `Tachidesk Electron Launcher.bat`: Launches Tachidesk inside Electron as a desktop applicaton
|
||||
- `Tachidesk Browser Launcher.bat`: Launches Tachidesk in a browser window
|
||||
- `Tachidesk Debug Launcher.bat`: Launches Tachidesk with debug logs attached. If Tachidesk doesn't work for you, running this can give you insight into why.
|
||||
Unzip the downloaded file and double click on one of the launcher scripts.
|
||||
|
||||
### macOS
|
||||
Download the latest `macOS-x64`(older macOS systems) or `macOS-arm64`(Apple M1) release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview one from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
|
||||
|
||||
Unzip the downloaded file and double click on one of the launcher scripts.
|
||||
|
||||
### GNU/Linux
|
||||
Download the latest `linux-x64`(x86_64) release from [the releases section](https://github.com/Suwayomi/Tachidesk/releases) or a preview one from [the preview repository](https://github.com/Suwayomi/Tachidesk-preview/releases).
|
||||
|
||||
`tar xvf` the downloaded file and double click on one of the launcher scripts or run them using the terminal.
|
||||
|
||||
## Other methods of getting Tachidesk
|
||||
### Arch Linux
|
||||
You can install Tachidesk from the AUR
|
||||
```
|
||||
@@ -65,7 +87,7 @@ yay -S tachidesk
|
||||
```
|
||||
|
||||
### Docker
|
||||
Check our Offical Docker release [Tachidesk Container](https://github.com/orgs/Suwayomi/packages/container/package/tachidesk) or use [arbuilder's](https://github.com/arbuilder/Tachidesk-docker) tachidesk docker repo for installation. Source code for our container is available at [docker-tachidesk](https://github.com/Suwayomi/docker-tachidesk). By default the server will be running on http://localhost:4567 open this url in your browser.
|
||||
Check our Official Docker release [Tachidesk Container](https://github.com/orgs/Suwayomi/packages/container/package/tachidesk) for running Tachidesk Server in a docker container. Source code for our container is available at [docker-tachidesk](https://github.com/Suwayomi/docker-tachidesk). By default the server will be running on http://localhost:4567 open this url in your browser.
|
||||
|
||||
Install from the command line:
|
||||
```
|
||||
|
||||
+39
-18
@@ -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,31 +68,30 @@ configure(projects) {
|
||||
testImplementation(kotlin("test-junit5"))
|
||||
|
||||
// coroutines
|
||||
val coroutinesVersion = "1.5.0"
|
||||
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")
|
||||
|
||||
val kotlinSerializationVersion = "1.2.1"
|
||||
val kotlinSerializationVersion = "1.3.0-RC"
|
||||
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.5.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")
|
||||
implementation("io.reactivex:rxkotlin:1.0.0")
|
||||
implementation("com.jakewharton.rxrelay:rxrelay:1.2.0")
|
||||
|
||||
// JSoup
|
||||
implementation("org.jsoup:jsoup:1.13.1")
|
||||
// dependency both in AndroidCompat and extensions, version locked by Tachiyomi app/extensions
|
||||
implementation("org.jsoup:jsoup:1.14.2")
|
||||
|
||||
// dependency of :AndroidCompat:Config
|
||||
implementation("com.typesafe:config:1.4.1")
|
||||
@@ -80,14 +101,14 @@ configure(projects) {
|
||||
implementation("net.harawata:appdirs:1.2.1")
|
||||
|
||||
// dex2jar
|
||||
val dex2jarVersion = "v21"
|
||||
val dex2jarVersion = "v26"
|
||||
implementation("com.github.ThexXTURBOXx.dex2jar:dex-translator:$dex2jarVersion")
|
||||
implementation("com.github.ThexXTURBOXx.dex2jar:dex-tools:$dex2jarVersion")
|
||||
|
||||
// APK parser
|
||||
implementation("net.dongliu:apk-parser:2.6.10")
|
||||
|
||||
// Jackson
|
||||
implementation("com.fasterxml.jackson.core:jackson-annotations:2.10.3")
|
||||
// dependency both in AndroidCompat and server, version locked by javalin
|
||||
implementation("com.fasterxml.jackson.core:jackson-annotations:2.12.4")
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,16 @@ import java.io.BufferedReader
|
||||
* 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/. */
|
||||
|
||||
const val kotlinVersion = "1.5.21"
|
||||
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.4.8"
|
||||
val tachideskVersion = System.getenv("ProductVersion") ?: "v0.5.3"
|
||||
|
||||
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r41"
|
||||
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r809"
|
||||
|
||||
// counts commits on the the master branch
|
||||
// counts commits on the master branch
|
||||
val tachideskRevision = runCatching {
|
||||
System.getenv("ProductRevision") ?: Runtime
|
||||
.getRuntime()
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
cd "`dirname "$0"`"
|
||||
|
||||
./jre/Contents/Home/bin/java -jar Tachidesk.jar
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
cd "`dirname "$0"`"
|
||||
|
||||
./jre/Contents/Home/bin/java -Dsuwayomi.tachidesk.config.server.debugLogsEnabled=true -jar Tachidesk.jar
|
||||
@@ -0,0 +1,3 @@
|
||||
cd "`dirname "$0"`"
|
||||
|
||||
./jre/Contents/Home/bin/java "-Dsuwayomi.tachidesk.config.server.webUIInterface=electron" "-Dsuwayomi.tachidesk.config.server.electronPath=electron/Electron.app/Contents/MacOS/Electron" -jar Tachidesk.jar
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
./jre/bin/java -jar Tachidesk.jar
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
./jre/bin/java -Dsuwayomi.tachidesk.config.server.debugLogsEnabled=true -jar Tachidesk.jar
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
./jre/bin/java "-Dsuwayomi.tachidesk.config.server.webUIInterface=electron" "-Dsuwayomi.tachidesk.config.server.electronPath=electron/electron" -jar Tachidesk.jar
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#/bin/bash
|
||||
|
||||
# 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/.
|
||||
|
||||
electron_version="v14.0.0"
|
||||
|
||||
if [ $1 = "linux-x64" ]; then
|
||||
jre="OpenJDK8U-jre_x64_linux_hotspot_8u302b08.tar.gz"
|
||||
jre_release="jdk8u302-b08"
|
||||
jre_url="https://github.com/adoptium/temurin8-binaries/releases/download/$jre_release/$jre"
|
||||
jre_dir="$jre_release-jre"
|
||||
electron="electron-$electron_version-linux-x64.zip"
|
||||
elif [ $1 = "macOS-x64" ]; then
|
||||
jre="OpenJDK8U-jre_x64_mac_hotspot_8u302b08.tar.gz"
|
||||
jre_release="jdk8u302-b08"
|
||||
jre_url="https://github.com/adoptium/temurin8-binaries/releases/download/$jre_release/$jre"
|
||||
jre_dir="$jre_release-jre"
|
||||
electron="electron-$electron_version-darwin-x64.zip"
|
||||
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/zulu-8.jre"
|
||||
electron="electron-$electron_version-darwin-arm64.zip"
|
||||
else
|
||||
echo "Unsupported arch value: $1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
arch="$1"
|
||||
os=$(echo $arch | cut -d '-' -f1)
|
||||
|
||||
echo "creating $arch bundle"
|
||||
|
||||
jar=$(ls ../server/build/*.jar | tail -n1)
|
||||
jar_name=$(echo $jar | cut -d'/' -f4)
|
||||
release_name=$(echo $jar_name | sed 's/.jar//')-$arch
|
||||
|
||||
|
||||
# make release dir
|
||||
mkdir $release_name
|
||||
|
||||
|
||||
echo "Dealing with jre..."
|
||||
if [ ! -f $jre ]; then
|
||||
curl -L $jre_url -o $jre
|
||||
fi
|
||||
tar xvf $jre
|
||||
mv $jre_dir $release_name/jre
|
||||
|
||||
echo "Dealing with electron"
|
||||
if [ ! -f $electron ]; then
|
||||
curl -L "https://github.com/electron/electron/releases/download/$electron_version/$electron" -o $electron
|
||||
fi
|
||||
unzip $electron -d $release_name/electron
|
||||
|
||||
# copy artifacts
|
||||
cp $jar $release_name/Tachidesk.jar
|
||||
if [ $os = linux ]; then
|
||||
cp "resources/tachidesk-browser-launcher.sh" $release_name
|
||||
cp "resources/tachidesk-debug-launcher.sh" $release_name
|
||||
cp "resources/tachidesk-electron-launcher.sh" $release_name
|
||||
elif [ $os = macOS ]; then
|
||||
cp "resources/Tachidesk Browser Launcher.command" $release_name
|
||||
cp "resources/Tachidesk Debug Launcher.command" $release_name
|
||||
cp "resources/Tachidesk Electron Launcher.command" $release_name
|
||||
fi
|
||||
|
||||
archive_name=""
|
||||
if [ $os = linux ]; then
|
||||
archive_name=$release_name.tar.gz
|
||||
GZIP=-9 tar cvzf $archive_name $release_name
|
||||
elif [ $os = macOS ]; then
|
||||
archive_name=$release_name.zip
|
||||
zip -9 -r $archive_name $release_name
|
||||
fi
|
||||
|
||||
rm -rf $release_name
|
||||
|
||||
# clean up from possible previous runs
|
||||
if [ -f ../server/build/$archive_name ]; then
|
||||
rm ../server/build/$archive_name
|
||||
fi
|
||||
|
||||
mv $archive_name ../server/build/
|
||||
@@ -6,19 +6,23 @@
|
||||
# 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/.
|
||||
|
||||
electron_version="v12.0.9"
|
||||
electron_version="v14.0.0"
|
||||
|
||||
if [ $1 = "win32" ]; then
|
||||
jre="OpenJDK8U-jre_x86-32_windows_hotspot_8u292b10.zip"
|
||||
jre_release="jdk8u292-b10"
|
||||
jre_url="https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/$jre_release/$jre"
|
||||
arch="win32"
|
||||
electron="electron-$electron_version-win32-ia32.zip"
|
||||
else
|
||||
jre="OpenJDK8U-jre_x64_windows_hotspot_8u292b10.zip"
|
||||
jre="OpenJDK8U-jre_x64_windows_hotspot_8u302b08.zip"
|
||||
jre_release="jdk8u302-b08"
|
||||
jre_url="https://github.com/adoptium/temurin8-binaries/releases/download/$jre_release/$jre"
|
||||
arch="win64"
|
||||
electron="electron-$electron_version-win32-x64.zip"
|
||||
fi
|
||||
|
||||
jre_dir="jdk8u292-b10-jre"
|
||||
jre_dir="$jre_release-jre"
|
||||
|
||||
echo "creating windows bundle"
|
||||
|
||||
@@ -33,7 +37,7 @@ mkdir $release_name
|
||||
|
||||
echo "Dealing with jre..."
|
||||
if [ ! -f $jre ]; then
|
||||
curl -L "https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/jdk8u292-b10/$jre" -o $jre
|
||||
curl -L $jre_url -o $jre
|
||||
fi
|
||||
unzip $jre
|
||||
mv $jre_dir $release_name/jre
|
||||
|
||||
+26
-64
@@ -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.5.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,13 +16,14 @@ dependencies {
|
||||
implementation("com.squareup.okio:okio:2.10.0")
|
||||
|
||||
// Javalin api
|
||||
implementation("io.javalin:javalin:3.13.6")
|
||||
// jackson version is tied to 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")
|
||||
implementation("io.javalin:javalin:4.0.0")
|
||||
// jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
|
||||
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.31.1"
|
||||
val exposedVersion = "0.34.1"
|
||||
implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
|
||||
implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion")
|
||||
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
|
||||
@@ -46,28 +32,29 @@ dependencies {
|
||||
implementation("com.h2database:h2:1.4.200")
|
||||
|
||||
// Exposed Migrations
|
||||
val exposedMigrationsVersion = "3.1.1"
|
||||
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")
|
||||
|
||||
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", "downloadWebUI")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.jetbrains.exposed.dao.id.IntIdTable
|
||||
import org.jetbrains.exposed.sql.ResultRow
|
||||
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
|
||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
|
||||
import suwayomi.tachidesk.manga.model.table.MangaStatus.Companion
|
||||
|
||||
object AnimeTable : IntIdTable() {
|
||||
@@ -48,7 +49,7 @@ fun AnimeTable.toDataClass(mangaEntry: ResultRow) =
|
||||
mangaEntry[artist],
|
||||
mangaEntry[author],
|
||||
mangaEntry[description],
|
||||
mangaEntry[genre],
|
||||
mangaEntry[genre].toGenreList(),
|
||||
Companion.valueOf(mangaEntry[status]).name,
|
||||
mangaEntry[inLibrary]
|
||||
)
|
||||
|
||||
@@ -19,64 +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("update/:pkgName", ExtensionController::update)
|
||||
get("uninstall/:pkgName", ExtensionController::uninstall)
|
||||
get("install/{pkgName}", ExtensionController::install)
|
||||
post("install", ExtensionController::installFile)
|
||||
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)
|
||||
}
|
||||
@@ -101,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())
|
||||
}
|
||||
)
|
||||
@@ -29,8 +29,9 @@ object BackupController {
|
||||
|
||||
/** expects a Tachiyomi protobuf backup as a file upload, the file must be named "backup.proto.gz" */
|
||||
fun protobufImportFile(ctx: Context) {
|
||||
ctx.json(
|
||||
JavalinSetup.future {
|
||||
// TODO: rewrite this with ctx.uploadedFiles(), don't call the multipart field "backup.proto.gz"
|
||||
ctx.future(
|
||||
future {
|
||||
ProtoBackupImport.performRestore(ctx.uploadedFile("backup.proto.gz")!!.content)
|
||||
}
|
||||
)
|
||||
@@ -39,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,
|
||||
@@ -60,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,
|
||||
@@ -77,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())
|
||||
}
|
||||
)
|
||||
@@ -86,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)
|
||||
|
||||
@@ -8,14 +8,17 @@ package suwayomi.tachidesk.manga.controller
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import io.javalin.http.Context
|
||||
import mu.KotlinLogging
|
||||
import suwayomi.tachidesk.manga.impl.extension.Extension
|
||||
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList
|
||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||
|
||||
object ExtensionController {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
/** list all extensions */
|
||||
fun list(ctx: Context) {
|
||||
ctx.json(
|
||||
ctx.future(
|
||||
future {
|
||||
ExtensionsList.getExtensionList()
|
||||
}
|
||||
@@ -26,18 +29,31 @@ object ExtensionController {
|
||||
fun install(ctx: Context) {
|
||||
val pkgName = ctx.pathParam("pkgName")
|
||||
|
||||
ctx.json(
|
||||
ctx.future(
|
||||
future {
|
||||
Extension.installExtension(pkgName)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** install the uploaded apk file */
|
||||
fun installFile(ctx: Context) {
|
||||
|
||||
val uploadedFile = ctx.uploadedFile("file")!!
|
||||
logger.debug { "Uploaded extension file name: " + uploadedFile.filename }
|
||||
|
||||
ctx.future(
|
||||
future {
|
||||
Extension.installExternalExtension(uploadedFile.content, uploadedFile.filename)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** update extension identified with "pkgName" */
|
||||
fun update(ctx: Context) {
|
||||
val pkgName = ctx.pathParam("pkgName")
|
||||
|
||||
ctx.json(
|
||||
ctx.future(
|
||||
future {
|
||||
Extension.updateExtension(pkgName)
|
||||
}
|
||||
@@ -55,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,9 +22,11 @@ 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
|
||||
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
@@ -56,9 +58,10 @@ object Manga {
|
||||
mangaEntry[MangaTable.artist],
|
||||
mangaEntry[MangaTable.author],
|
||||
mangaEntry[MangaTable.description],
|
||||
mangaEntry[MangaTable.genre],
|
||||
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],
|
||||
@@ -75,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
|
||||
@@ -85,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +119,10 @@ object Manga {
|
||||
fetchedManga.artist,
|
||||
fetchedManga.author,
|
||||
fetchedManga.description,
|
||||
fetchedManga.genre,
|
||||
fetchedManga.genre.toGenreList(),
|
||||
MangaStatus.valueOf(fetchedManga.status).name,
|
||||
mangaEntry[MangaTable.inLibrary],
|
||||
mangaEntry[MangaTable.inLibraryAt],
|
||||
getSource(mangaEntry[MangaTable.sourceReference]),
|
||||
getMangaMetaMap(mangaId),
|
||||
mangaEntry[MangaTable.realUrl],
|
||||
@@ -145,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)
|
||||
|
||||
@@ -8,6 +8,7 @@ package suwayomi.tachidesk.manga.impl
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.insertAndGetId
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
@@ -16,6 +17,7 @@ import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
|
||||
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
|
||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||
import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass
|
||||
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
|
||||
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
|
||||
@@ -41,7 +43,9 @@ object MangaList {
|
||||
val mangasPage = this
|
||||
val mangaList = transaction {
|
||||
return@transaction mangasPage.mangas.map { manga ->
|
||||
var mangaEntry = MangaTable.select { MangaTable.url eq manga.url }.firstOrNull()
|
||||
var mangaEntry = MangaTable.select {
|
||||
(MangaTable.url eq manga.url) and (MangaTable.sourceReference eq sourceId)
|
||||
}.firstOrNull()
|
||||
if (mangaEntry == null) { // create manga entry
|
||||
val mangaId = MangaTable.insertAndGetId {
|
||||
it[url] = manga.url
|
||||
@@ -57,7 +61,9 @@ object MangaList {
|
||||
it[sourceReference] = sourceId
|
||||
}.value
|
||||
|
||||
mangaEntry = MangaTable.select { MangaTable.url eq manga.url }.first()
|
||||
mangaEntry = MangaTable.select {
|
||||
(MangaTable.url eq manga.url) and (MangaTable.sourceReference eq sourceId)
|
||||
}.first()
|
||||
|
||||
MangaDataClass(
|
||||
mangaId,
|
||||
@@ -72,9 +78,10 @@ object MangaList {
|
||||
manga.artist,
|
||||
manga.author,
|
||||
manga.description,
|
||||
manga.genre,
|
||||
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
|
||||
@@ -94,9 +101,10 @@ object MangaList {
|
||||
mangaEntry[MangaTable.artist],
|
||||
mangaEntry[MangaTable.author],
|
||||
mangaEntry[MangaTable.description],
|
||||
mangaEntry[MangaTable.genre],
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ object ProtoBackupExport : ProtoBackupBase() {
|
||||
Backup(
|
||||
backupManga(databaseManga, flags),
|
||||
backupCategories(),
|
||||
emptyList(),
|
||||
backupExtensionInfo(databaseManga)
|
||||
)
|
||||
}
|
||||
|
||||
+4
-4
@@ -62,7 +62,7 @@ object ProtoBackupImport : ProtoBackupBase() {
|
||||
}
|
||||
|
||||
// Store source mapping for error messages
|
||||
sourceMapping = backup.backupSources.map { it.sourceId to it.name }.toMap()
|
||||
sourceMapping = backup.getSourceMap()
|
||||
|
||||
// Restore individual manga
|
||||
backup.backupManga.forEach {
|
||||
@@ -103,7 +103,7 @@ object ProtoBackupImport : ProtoBackupBase() {
|
||||
val manga = backupManga.getMangaImpl()
|
||||
val chapters = backupManga.getChaptersImpl()
|
||||
val categories = backupManga.categories
|
||||
val history = backupManga.history
|
||||
val history = backupManga.brokenHistory.map { BackupHistory(it.url, it.lastRead) } + backupManga.history
|
||||
val tracks = backupManga.getTrackingImpl()
|
||||
|
||||
try {
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ object ProtoBackupValidator : AbstractBackupValidator() {
|
||||
throw Exception("Backup does not contain any manga.")
|
||||
}
|
||||
|
||||
val sources = backup.backupSources.map { it.sourceId to it.name }.toMap()
|
||||
val sources = backup.getSourceMap()
|
||||
|
||||
val missingSources = transaction {
|
||||
sources
|
||||
|
||||
@@ -8,5 +8,11 @@ data class Backup(
|
||||
@ProtoNumber(1) val backupManga: List<BackupManga>,
|
||||
@ProtoNumber(2) var backupCategories: List<BackupCategory> = emptyList(),
|
||||
// Bump by 100 to specify this is a 0.x value
|
||||
@ProtoNumber(100) var backupSources: List<BackupSource> = emptyList(),
|
||||
)
|
||||
@ProtoNumber(100) var brokenBackupSources: List<BrokenBackupSource> = emptyList(),
|
||||
@ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(),
|
||||
) {
|
||||
fun getSourceMap(): Map<Long, String> {
|
||||
return (brokenBackupSources.map { BackupSource(it.name, it.sourceId) } + backupSources)
|
||||
.associate { it.sourceId to it.name }
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -4,7 +4,13 @@ import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.protobuf.ProtoNumber
|
||||
|
||||
@Serializable
|
||||
data class BackupHistory(
|
||||
data class BrokenBackupHistory(
|
||||
@ProtoNumber(0) var url: String,
|
||||
@ProtoNumber(1) var lastRead: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BackupHistory(
|
||||
@ProtoNumber(1) var url: String,
|
||||
@ProtoNumber(2) var lastRead: Long
|
||||
)
|
||||
|
||||
+3
-2
@@ -33,8 +33,9 @@ data class BackupManga(
|
||||
// Bump by 100 for values that are not saved/implemented in 1.x but are used in 0.x
|
||||
@ProtoNumber(100) var favorite: Boolean = true,
|
||||
@ProtoNumber(101) var chapterFlags: Int = 0,
|
||||
@ProtoNumber(102) var history: List<BackupHistory> = emptyList(),
|
||||
@ProtoNumber(103) var viewer_flags: Int? = null
|
||||
@ProtoNumber(102) var brokenHistory: List<BrokenBackupHistory> = emptyList(),
|
||||
@ProtoNumber(103) var viewer_flags: Int? = null,
|
||||
@ProtoNumber(104) var history: List<BackupHistory> = emptyList(),
|
||||
) {
|
||||
fun getMangaImpl(): MangaImpl {
|
||||
return MangaImpl().apply {
|
||||
|
||||
+7
-1
@@ -5,9 +5,15 @@ import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.protobuf.ProtoNumber
|
||||
|
||||
@Serializable
|
||||
data class BackupSource(
|
||||
data class BrokenBackupSource(
|
||||
@ProtoNumber(0) var name: String = "",
|
||||
@ProtoNumber(1) var sourceId: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class BackupSource(
|
||||
@ProtoNumber(1) var name: String = "",
|
||||
@ProtoNumber(2) var sourceId: Long
|
||||
) {
|
||||
companion object {
|
||||
fun copyFrom(source: Source): BackupSource {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ package suwayomi.tachidesk.manga.impl.download
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import mu.KotlinLogging
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
@@ -21,6 +22,8 @@ import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Queued
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
class Downloader(private val downloadQueue: CopyOnWriteArrayList<DownloadChapter>, val notifier: () -> Unit) : Thread() {
|
||||
var shouldStop: Boolean = false
|
||||
|
||||
@@ -58,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
|
||||
}
|
||||
}
|
||||
@@ -67,10 +70,10 @@ class Downloader(private val downloadQueue: CopyOnWriteArrayList<DownloadChapter
|
||||
downloadQueue.removeIf { it.mangaId == download.mangaId && it.chapterIndex == download.chapterIndex }
|
||||
step()
|
||||
} catch (e: DownloadShouldStopException) {
|
||||
println("Downloader was stopped")
|
||||
logger.debug("Downloader was stopped")
|
||||
downloadQueue.filter { it.state == Downloading }.forEach { it.state = Queued }
|
||||
} catch (e: Exception) {
|
||||
println("Downloader faced an exception")
|
||||
logger.debug("Downloader faced an exception")
|
||||
downloadQueue.filter { it.state == Downloading }.forEach { it.state = Error; it.tries++ }
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
|
||||
@@ -17,6 +17,7 @@ import mu.KotlinLogging
|
||||
import okhttp3.Request
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import org.jetbrains.exposed.sql.deleteWhere
|
||||
import org.jetbrains.exposed.sql.insert
|
||||
import org.jetbrains.exposed.sql.select
|
||||
@@ -27,6 +28,8 @@ import org.kodein.di.conf.global
|
||||
import org.kodein.di.instance
|
||||
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.extensionTableAsDataClass
|
||||
import suwayomi.tachidesk.manga.impl.extension.github.ExtensionGithubApi
|
||||
import suwayomi.tachidesk.manga.impl.util.GetHttpSource
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.EXTENSION_FEATURE
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MAX
|
||||
import suwayomi.tachidesk.manga.impl.util.PackageTools.LIB_VERSION_MIN
|
||||
@@ -34,11 +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.PackageTools.trustedSignatures
|
||||
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
|
||||
@@ -68,7 +69,23 @@ object Extension {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun installAPK(fetcher: suspend () -> String): Int {
|
||||
suspend fun installExternalExtension(inputStream: InputStream, apkName: String): Int {
|
||||
return installAPK(true) {
|
||||
val savePath = "${applicationDirs.extensionsRoot}/$apkName"
|
||||
logger.debug { "Saving apk at $apkName" }
|
||||
// download apk file
|
||||
val downloadedFile = File(savePath)
|
||||
downloadedFile.sink().buffer().use { sink ->
|
||||
inputStream.source().use { source ->
|
||||
sink.writeAll(source)
|
||||
sink.flush()
|
||||
}
|
||||
}
|
||||
savePath
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun installAPK(forceReinstall: Boolean = false, fetcher: suspend () -> String): Int {
|
||||
val apkFilePath = fetcher()
|
||||
val apkName = File(apkFilePath).name
|
||||
|
||||
@@ -78,16 +95,19 @@ object Extension {
|
||||
ExtensionTable.select { ExtensionTable.apkName eq apkName }.firstOrNull()
|
||||
}?.get(ExtensionTable.isInstalled) ?: false
|
||||
|
||||
if (!isInstalled) {
|
||||
val fileNameWithoutType = apkName.substringBefore(".apk")
|
||||
val fileNameWithoutType = apkName.substringBefore(".apk")
|
||||
|
||||
val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
|
||||
val jarFilePath = "$dirPathWithoutType.jar"
|
||||
val dexFilePath = "$dirPathWithoutType.dex"
|
||||
val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
|
||||
val jarFilePath = "$dirPathWithoutType.jar"
|
||||
val dexFilePath = "$dirPathWithoutType.dex"
|
||||
|
||||
val packageInfo = getPackageInfo(apkFilePath)
|
||||
val pkgName = packageInfo.packageName
|
||||
val packageInfo = getPackageInfo(apkFilePath)
|
||||
val pkgName = packageInfo.packageName
|
||||
if (isInstalled && forceReinstall) {
|
||||
uninstallExtension(pkgName)
|
||||
}
|
||||
|
||||
if (!isInstalled || forceReinstall) {
|
||||
if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) {
|
||||
throw Exception("This apk is not a Tachiyomi extension")
|
||||
}
|
||||
@@ -101,14 +121,14 @@ 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")
|
||||
}
|
||||
// if (signatureHash == null) {
|
||||
// throw Exception("Package $pkgName isn't signed")
|
||||
// } else if (signatureHash !in trustedSignatures) {
|
||||
// throw Exception("This apk is not a signed with the official tachiyomi signature")
|
||||
// }
|
||||
|
||||
val isNsfw = packageInfo.applicationInfo.metaData.getString(METADATA_NSFW) == "1"
|
||||
|
||||
@@ -120,7 +140,7 @@ object Extension {
|
||||
dex2jar(apkFilePath, jarFilePath, fileNameWithoutType)
|
||||
|
||||
// clean up
|
||||
// File(apkFilePath).delete()
|
||||
File(apkFilePath).delete()
|
||||
File(dexFilePath).delete()
|
||||
|
||||
// collect sources from the extension
|
||||
@@ -155,6 +175,7 @@ object Extension {
|
||||
}
|
||||
|
||||
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
|
||||
it[this.apkName] = apkName
|
||||
it[this.isInstalled] = true
|
||||
it[this.classFQName] = className
|
||||
}
|
||||
@@ -200,19 +221,30 @@ object Extension {
|
||||
val extensionRecord = transaction { ExtensionTable.select { ExtensionTable.pkgName eq pkgName }.first() }
|
||||
val fileNameWithoutType = extensionRecord[ExtensionTable.apkName].substringBefore(".apk")
|
||||
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
|
||||
transaction {
|
||||
val sources = transaction {
|
||||
val extensionId = extensionRecord[ExtensionTable.id].value
|
||||
|
||||
val sources = SourceTable.select { SourceTable.extension eq extensionId }.map { it[SourceTable.id].value }
|
||||
|
||||
SourceTable.deleteWhere { SourceTable.extension eq extensionId }
|
||||
|
||||
if (extensionRecord[ExtensionTable.isObsolete])
|
||||
ExtensionTable.deleteWhere { ExtensionTable.pkgName eq pkgName }
|
||||
else
|
||||
ExtensionTable.update({ ExtensionTable.pkgName eq pkgName }) {
|
||||
it[isInstalled] = false
|
||||
}
|
||||
|
||||
sources
|
||||
}
|
||||
|
||||
if (File(jarPath).exists()) {
|
||||
// free up the file descriptor if exists
|
||||
PackageTools.jarLoaderMap.remove(jarPath)?.close()
|
||||
|
||||
// clear all loaded sources
|
||||
sources.forEach { GetHttpSource.invalidateSourceCache(it) }
|
||||
|
||||
File(jarPath).delete()
|
||||
}
|
||||
}
|
||||
@@ -235,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()
|
||||
|
||||
@@ -45,7 +45,7 @@ object PackageTools {
|
||||
|
||||
private const val officialSignature = "7ce04da7773d41b489f4693a366c36bcd0a11fc39b547168553c285bd7348e23" // inorichi's key
|
||||
private const val unofficialSignature = "64feb21075ba97ebc9cc981243645b331595c111cef1b0d084236a0403b00581" // ArMor's key
|
||||
var trustedSignatures = mutableSetOf<String>() + officialSignature + unofficialSignature
|
||||
val trustedSignatures = mutableSetOf<String>() + officialSignature + unofficialSignature
|
||||
|
||||
/**
|
||||
* Convert dex to jar, a wrapper for the dex2jar library
|
||||
@@ -82,7 +82,7 @@ object PackageTools {
|
||||
)
|
||||
handler.dump(errorFile, emptyArray<String>())
|
||||
} else {
|
||||
BytecodeEditor.fixAndroidClasses(jarFilePath.toFile())
|
||||
BytecodeEditor.fixAndroidClasses(jarFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,13 +136,19 @@ object PackageTools {
|
||||
}
|
||||
}
|
||||
|
||||
val jarLoaderMap = mutableMapOf<String, URLClassLoader>()
|
||||
|
||||
/**
|
||||
* loads the extension main class called [className] from the jar located at [jarPath]
|
||||
* It may return an instance of HttpSource or SourceFactory depending on the extension.
|
||||
*/
|
||||
fun loadExtensionSources(jarPath: String, className: String): Any {
|
||||
val classLoader = URLClassLoader(arrayOf<URL>(URL("file:$jarPath")))
|
||||
logger.debug { "loading jar with path: $jarPath" }
|
||||
val classLoader = jarLoaderMap[jarPath] ?: URLClassLoader(arrayOf<URL>(URL("file:$jarPath")))
|
||||
val classToLoad = Class.forName(className, false, classLoader)
|
||||
|
||||
jarLoaderMap[jarPath] = classLoader
|
||||
|
||||
return classToLoad.getDeclaredConstructor().newInstance()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package suwayomi.tachidesk.manga.impl.util.lang
|
||||
|
||||
fun List<String>.trimAll() = map { it.trim() }
|
||||
+28
-2
@@ -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,
|
||||
|
||||
|
||||
+13
@@ -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,
|
||||
)
|
||||
@@ -7,6 +7,7 @@ package suwayomi.tachidesk.manga.model.dataclass
|
||||
* 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 suwayomi.tachidesk.manga.impl.util.lang.trimAll
|
||||
import suwayomi.tachidesk.manga.model.table.MangaStatus
|
||||
|
||||
data class MangaDataClass(
|
||||
@@ -22,9 +23,10 @@ data class MangaDataClass(
|
||||
val artist: String? = null,
|
||||
val author: String? = null,
|
||||
val description: String? = null,
|
||||
val genre: String? = null,
|
||||
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 */
|
||||
@@ -39,3 +41,5 @@ data class PagedMangaListDataClass(
|
||||
val mangaList: List<MangaDataClass>,
|
||||
val hasNextPage: Boolean
|
||||
)
|
||||
|
||||
internal fun String?.toGenreList() = this?.split(",")?.trimAll().orEmpty()
|
||||
|
||||
@@ -22,5 +22,8 @@ data class SourceDataClass(
|
||||
val isConfigurable: Boolean?,
|
||||
|
||||
/** The Source class has a @Nsfw annotation */
|
||||
val isNSFW: Boolean?,
|
||||
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)
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.jetbrains.exposed.sql.ResultRow
|
||||
import suwayomi.tachidesk.manga.impl.Manga.getMangaMetaMap
|
||||
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
|
||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||
import suwayomi.tachidesk.manga.model.dataclass.toGenreList
|
||||
import suwayomi.tachidesk.manga.model.table.MangaStatus.Companion
|
||||
|
||||
object MangaTable : IntIdTable() {
|
||||
@@ -20,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")
|
||||
@@ -52,9 +54,10 @@ fun MangaTable.toDataClass(mangaEntry: ResultRow) =
|
||||
mangaEntry[artist],
|
||||
mangaEntry[author],
|
||||
mangaEntry[description],
|
||||
mangaEntry[genre],
|
||||
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))
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user