Compare commits

..

97 Commits

Author SHA1 Message Date
Aria Moradi 2c6d043277 bump version
CI Publish / Validate Gradle Wrapper (push) Successful in 12s
CI Publish / Build artifacts and release (push) Failing after 15s
2021-11-29 19:24:07 +03:30
Aria Moradi 9a226f7b64 update 2021-11-29 19:14:40 +03:30
Aria Moradi 3b73a0fd72 empty searchTerm support (#259)
* linter run

* convert search params to queryParams
2021-11-29 18:42:53 +03:30
Aria Moradi 2478aa77cd add support for MultiSelectListPreference (#258)
* add support for MultiSelectListPreference

* Update AndroidCompat/src/main/java/xyz/nulldev/androidcompat/io/sharedprefs/JavaSharedPreferences.kt

Co-authored-by: Mitchell Syer <Mitchellptbo@gmail.com>

* don't convert to list

* fix by @Syer10

Co-authored-by: Mitchell Syer <Mitchellptbo@gmail.com>
2021-11-28 23:11:56 +03:30
Aria Moradi a41068dbc9 migrate application directories (#255) 2021-11-28 19:56:06 +03:30
Aria Moradi 5e47b7ae6b Update README.md 2021-11-16 21:37:44 +03:30
Aria Moradi debf45a7d5 Update README.md 2021-11-16 21:09:16 +03:30
Aria Moradi e7041e8c8c Fix first page not being detected correctly (#253)
* fix first page not being recognized correctly

* fix first page not being recognized correctly
2021-11-15 23:49:02 +03:30
Aria Moradi bd960992bc cleanup directory names (#251) 2021-11-15 23:40:49 +03:30
Aria Moradi 0c5f6b432c update 2021-11-15 12:22:49 +03:30
Aria Moradi 49232edbd5 update 2021-11-15 12:06:04 +03:30
Mitchell Syer b02884f58d Add a Kotlin DSL for endpoint documentation (#249) 2021-11-14 18:16:39 +03:30
Aria Moradi 845b588426 Mention the existence of Mahor's Tachidesk-GTK 2021-11-13 13:51:20 +03:30
Aria Moradi 3a97d7c8be update 2021-11-13 13:27:17 +03:30
Sascha Hahne 2cb2ded2d9 Implement Update of Library/Category (#235)
* Implement Update Controller tests

* Basic Threading and notify

* WIP

* Reworked using coroutines

* Use Map for JobSummary Tracking

* Change Tests

* Clean up

* Changes based on review

* Rethrow cancellationexception

* Clean up

* Fix Merge Error

* Actually handle messages

* Clean up

* Remove useless annotation
2021-11-10 22:38:41 +03:30
Aria Moradi 14e02bee6c update 2021-11-10 12:17:26 +03:30
Aria Moradi 30f7cdc1ba add pagination to recentChapters (#246)
* add pagination to recentChapters

* Use kotlin native library

Co-authored-by: Mitchell Syer <Mitchellptbo@gmail.com>

Co-authored-by: Mitchell Syer <Mitchellptbo@gmail.com>
2021-11-08 18:45:52 +03:30
Mitchell Syer 420d14fc37 Fix Manga Meta, add Manga Meta test (#245)
* Fix Manga Meta, add Manga Meta test

* Tweak assertion strings
2021-11-08 03:08:22 +03:30
Aria Moradi 3d7953d977 add manga data to download queue object (#244)
* add manga data to download queue object

* fix lint erro
2021-11-07 21:32:57 +03:30
Aria Moradi 35238b3da1 stop supporting zero based image storage (#242)
* stop supporting zero based image storage, closes #210

* add test
2021-11-07 21:27:11 +03:30
Aria Moradi 446f4283e0 Update CONTRIBUTING.md 2021-11-02 15:58:30 +03:30
Aria Moradi 8062dd3709 convert request type 2021-11-02 12:14:44 +03:30
Aria Moradi 41c8fde8c5 ignore build artifacts generated by teting 2021-11-02 12:13:33 +03:30
Aria Moradi d90b986d19 implement Source Filters 2021-11-02 04:15:08 +03:30
Aria Moradi 64ea8416b2 refactor 2021-11-01 23:46:46 +03:30
Aria Moradi 100a4c9d35 refactor 2021-11-01 18:02:06 +03:30
Aria Moradi 4ef6dec89a cleanup 2021-11-01 02:10:51 +03:30
Aria Moradi a14cdc48bd fix credit 2021-11-01 02:09:14 +03:30
Aria Moradi 8a4ddbc6df update 2021-11-01 02:05:02 +03:30
Aria Moradi ee33acc561 allow injecting Sources 2021-10-31 18:05:55 +03:30
Aria Moradi 5fe69becf3 improve tests 2021-10-31 17:31:46 +03:30
Aria Moradi d460d3ccdf change windows bundle names 2021-10-31 14:06:59 +03:30
Sascha Hahne 0ee74943e8 Fix category reorder Endpoint. Added Test for Category Reorder (#232)
* Fix category reorder Endpoint. Added Test for Category Reorder

* Remove streams and use kotlin filtering
2021-10-30 14:46:22 +03:30
Aria Moradi 82837e38d2 update 2021-10-30 14:25:23 +03:30
Sascha Hahne 91df90d760 Fix broken test (#231) 2021-10-29 19:40:20 +03:30
Mitchell Syer 1ee37da720 Fix unread and download counts casing (#230) 2021-10-28 19:11:10 +03:30
Aria Moradi 6f8fc5b69d Update README.md 2021-10-28 12:35:24 +03:30
Sascha Hahne be1918c769 add Cache Header to Thumbnail Response for improved library performance (#228) 2021-10-26 21:46:38 +03:30
Sascha Hahne 0057b35a0a Expose unread and download count of Manga in category api (#227)
* #224 Created view for unread and download badges

* #224 Basic test structure

* Created test and cleaned up a bit

* Move counts to MangaDataClass and delete MangaViewDataClass

* Readded trailing space

* Removed SQL view and calculate with joins now
2021-10-25 13:41:07 +03:30
Sascha Hahne d12974702a Fix tests (#226) 2021-10-24 23:19:27 +03:30
Aria Moradi 921c41689d update 2021-10-24 23:08:56 +03:30
Aria Moradi 6389899507 remove anime support 2021-10-24 22:58:25 +03:30
Aria Moradi 92ede2a2b3 Update README.md 2021-10-24 17:28:02 +03:30
Aria Moradi 826a63ed71 Update README.md 2021-10-24 16:57:06 +03:30
Aria Moradi d1576a2a72 Update README.md 2021-10-24 14:27:12 +03:30
Aria Moradi 95f218d704 Update README.md 2021-10-23 20:36:45 +03:30
Aria Moradi 315d3a0ac0 Update CONTRIBUTING.md 2021-10-23 20:04:10 +03:30
Aria Moradi 954818cef2 Update CONTRIBUTING.md 2021-10-23 20:02:42 +03:30
Aria Moradi 5a95ca9b1b Update CONTRIBUTING.md 2021-10-23 20:00:35 +03:30
Aria Moradi c1e6f4c26e better cleaning algorithm 2021-10-23 19:27:50 +03:30
Aria Moradi 7c603258fb Update README.md 2021-10-21 14:32:58 +03:30
Mahor fcbc582686 Update README.md (#223)
Add PPA's description for Ubuntu-based distributions
2021-10-20 12:15:46 +03:30
Aria Moradi 9c4906b90b cleanup 2021-10-20 08:34:07 +03:30
Aria Moradi d35e31e02d bump version
CI Publish / Validate Gradle Wrapper (push) Successful in 16s
CI Publish / Build artifacts and release (push) Failing after 15s
2021-10-18 14:41:01 +03:30
Aria Moradi 6c4ca36c09 update WebUI 2021-10-18 14:37:13 +03:30
Aria Moradi 70355dc505 update 2021-10-11 00:21:10 +03:30
Aria Moradi 20aeaf2a05 update 2021-10-11 00:18:49 +03:30
Mitchell Syer 1f13e1d08b Use a custom task to run electron (#220) 2021-10-11 00:16:32 +03:30
Aria Moradi eb9d35c123 code cleanup 2021-10-11 00:15:15 +03:30
Mitchell Syer 45808cd530 Support using a CatalogueSource instead of only HttpSources (#219) 2021-10-11 00:01:04 +03:30
Aria Moradi fd715a3f92 fix workflow 2021-10-10 23:52:35 +03:30
Aria Moradi e3b32367a7 update 2021-10-10 23:51:21 +03:30
Aria Moradi bf9554a746 update 2021-10-10 23:32:19 +03:30
Aria Moradi b9f8ca1488 update 2021-10-10 23:01:58 +03:30
Aria Moradi e8c4159678 use correct conversion units 2021-10-10 21:31:38 +03:30
Aria Moradi e57e71629e update 2021-10-10 21:05:24 +03:30
Aria Moradi 2bfd9d24a4 remove isNsfw annotation detection 2021-10-10 21:04:28 +03:30
Aria Moradi b18b8fe22f update 2021-10-10 20:58:10 +03:30
Aria Moradi b154ff2f9d fix export chapter ordering, include new props in backup 2021-10-10 20:53:50 +03:30
Aria Moradi e9b764b63c update 2021-10-10 12:27:50 +03:30
Aria Moradi 7216b97d92 mimic Tachyomi's behaviour more closely, fixes ReadComicOnline (EN) 2021-10-10 12:18:21 +03:30
Aria Moradi 0e9d93b194 prepare CHANGELOG 2021-10-06 21:18:21 +03:30
Aria Moradi 2cbee62f0a update docs 2021-09-28 18:06:20 +03:30
Aria Moradi 379e9da5fe clenup 2021-09-28 18:00:24 +03:30
Aria Moradi ae7caa4901 merge 2021-09-28 17:57:55 +03:30
Aria Moradi cd8b4c9dd7 convert android.jar lib to a maven repo 2021-09-28 17:55:50 +03:30
Aria Moradi 60cd61dfd2 Update README.md 2021-09-28 03:42:20 +03:30
Aria Moradi 5a6637d9fc Update README.md 2021-09-28 03:39:45 +03:30
Aria Moradi dca7ed23f5 Update README.md 2021-09-28 03:36:06 +03:30
Aria Moradi 8cb5791f3b Update README.md 2021-09-28 03:34:14 +03:30
Aria Moradi 9b67f2c58f Update CHANGELOG.md 2021-09-28 03:24:57 +03:30
Aria Moradi 3815810d4f Update for release 2021-09-28 01:11:24 +03:30
Aria Moradi 819ceba17d bump version
CI Publish / Validate Gradle Wrapper (push) Successful in 12s
CI Publish / Build artifacts and release (push) Failing after 16s
2021-09-28 00:52:49 +03:30
Aria Moradi 0aa0d62e03 update changelog file and it's template 2021-09-28 00:51:05 +03:30
Aria Moradi b3e2a35880 update WebUI 2021-09-28 00:50:33 +03:30
Aria Moradi 15ec20c65d fix sorting 2021-09-27 20:27:40 +03:30
Aria Moradi d4d6d7e12f add recentChapters endpoint 2021-09-27 18:27:05 +03:30
Aria Moradi 2e7a4f1421 remove no longer relevant comment 2021-09-27 14:44:48 +03:30
Aria Moradi ab8a52faf3 rename ChapterTable.chapterIndex to ChapterTable.sourceOrder 2021-09-27 14:36:06 +03:30
Aria Moradi bd465559fb Update README.md 2021-09-26 23:48:29 +03:30
Aria Moradi 13ec45a95c aftermath of adding kotlinter to all modules 2021-09-25 04:34:02 +03:30
Mitchell Syer 13b034875b Workaround StdLib issue and add KtLint to all modules (#206)
* Workaround buildconfig kotlin stdlib issue

* Add KtLint to all modules
2021-09-25 04:31:03 +03:30
Aria Moradi bb701fb088 fix macOS-arm64 java path 2021-09-24 14:06:19 +03:30
Aria Moradi b367414865 changes 2021-09-24 13:56:26 +03:30
Aria Moradi 4b00eec608 update CHANGELOG 2021-09-19 18:01:13 +04:30
Aria Moradi 5e11b51152 update CHANGELOG 2021-09-19 17:59:37 +04:30
Aria Moradi 9fb43b996e CHANGELOG update 2021-09-19 17:39:28 +04:30
138 changed files with 2895 additions and 3623 deletions
-4
View File
@@ -45,10 +45,6 @@ jobs:
mkdir -p ~/.gradle mkdir -p ~/.gradle
cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties
- name: Download android.jar
run: |
cd master
curl https://raw.githubusercontent.com/Suwayomi/Tachidesk/android-jar/android.jar -o AndroidCompat/lib/android.jar
- name: Build Jar - name: Build Jar
uses: eskatos/gradle-command-action@v1 uses: eskatos/gradle-command-action@v1
-16
View File
@@ -47,11 +47,6 @@ jobs:
mkdir -p ~/.gradle mkdir -p ~/.gradle
cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties
- name: Download android.jar
run: |
cd master
curl https://raw.githubusercontent.com/Suwayomi/Tachidesk/android-jar/android.jar -o AndroidCompat/lib/android.jar
- name: Build Jar - name: Build Jar
uses: eskatos/gradle-command-action@v1 uses: eskatos/gradle-command-action@v1
env: env:
@@ -64,12 +59,6 @@ jobs:
dependencies-cache-enabled: true dependencies-cache-enabled: true
configuration-cache-enabled: true configuration-cache-enabled: true
# - name: Mock Build and copy webUI, Build Jar
# run: |
# mkdir -p master/server/build
# cd master/server/build
# echo "test" > Tachidesk-v0.3.8-r583.jar
- name: Generate Tag Name - name: Generate Tag Name
id: GenTagName id: GenTagName
run: | run: |
@@ -87,11 +76,6 @@ jobs:
./unix-bundler.sh macOS-x64 ./unix-bundler.sh macOS-x64
./unix-bundler.sh macOS-arm64 ./unix-bundler.sh macOS-arm64
# - name: Mock make windows packages
# run: |
# cd master/server/build
# echo test > Tachidesk-v0.3.8-r580-win32.zip
- name: Checkout preview branch - name: Checkout preview branch
uses: actions/checkout@v2 uses: actions/checkout@v2
with: with:
-5
View File
@@ -46,11 +46,6 @@ jobs:
mkdir -p ~/.gradle mkdir -p ~/.gradle
cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties cp .github/runner-files/ci-gradle.properties ~/.gradle/gradle.properties
- name: Download android.jar
run: |
cd master
curl https://raw.githubusercontent.com/Suwayomi/Tachidesk/android-jar/android.jar -o AndroidCompat/lib/android.jar
- name: Build and copy webUI, Build Jar - name: Build and copy webUI, Build Jar
uses: eskatos/gradle-command-action@v1 uses: eskatos/gradle-command-action@v1
env: env:
+12 -8
View File
@@ -1,17 +1,21 @@
# Ignore Gradle project-specific cache directory # Ignore project-specific local files and dirs
.gradle .gradle
.idea .idea
gradle.properties gradle.properties
# But we need these
!.idea/runConfigurations
# Ignore Gradle build output directory # Ignore Gradle build output directory
build build
server/out
AndroidCompat/out
# WebUI is either to be downloaded on-demand or is a dynamic build asset
server/src/main/resources/WebUI.zip server/src/main/resources/WebUI.zip
server/tmp/
server/tachiserver-data/
# bundle asset downlaods # bundling stage downlaoded assets
OpenJDK*.* scripts/OpenJDK*
zulu*jre* scripts/zulu*
electron-*.* scripts/electron-*
rcedit-* scripts/rcedit-*
@@ -14,7 +14,7 @@ const val CONFIG_PREFIX = "suwayomi.tachidesk.config"
val ApplicationRootDir: String val ApplicationRootDir: String
get(): String { get(): String {
return System.getProperty( return System.getProperty(
"$CONFIG_PREFIX.server.rootDir", "$CONFIG_PREFIX.server.rootDir",
AppDirsFactory.getInstance().getUserDataDir("Tachidesk", null, null) AppDirsFactory.getInstance().getUserDataDir("Tachidesk", null, null)
) )
} }
@@ -6,7 +6,7 @@ import org.kodein.di.singleton
class ConfigKodeinModule { class ConfigKodeinModule {
fun create() = DI.Module("ConfigManager") { fun create() = DI.Module("ConfigManager") {
//Config module // Config module
bind<ConfigManager>() with singleton { GlobalConfigManager } bind<ConfigManager>() with singleton { GlobalConfigManager }
} }
} }
@@ -21,7 +21,7 @@ open class ConfigManager {
private val generatedModules = mutableMapOf<Class<out ConfigModule>, ConfigModule>() private val generatedModules = mutableMapOf<Class<out ConfigModule>, ConfigModule>()
val config by lazy { loadConfigs() } val config by lazy { loadConfigs() }
//Public read-only view of modules // Public read-only view of modules
val loadedModules: Map<Class<out ConfigModule>, ConfigModule> val loadedModules: Map<Class<out ConfigModule>, ConfigModule>
get() = generatedModules get() = generatedModules
@@ -42,29 +42,28 @@ open class ConfigManager {
* Load configs * Load configs
*/ */
fun loadConfigs(): Config { fun loadConfigs(): Config {
//Load reference configs // Load reference configs
val compatConfig = ConfigFactory.parseResources("compat-reference.conf") val compatConfig = ConfigFactory.parseResources("compat-reference.conf")
val serverConfig = ConfigFactory.parseResources("server-reference.conf") val serverConfig = ConfigFactory.parseResources("server-reference.conf")
val baseConfig = val baseConfig =
ConfigFactory.parseMap( ConfigFactory.parseMap(
mapOf( mapOf(
"androidcompat.rootDir" to "$ApplicationRootDir/android-compat" // override AndroidCompat's rootDir "androidcompat.rootDir" to "$ApplicationRootDir/android-compat" // override AndroidCompat's rootDir
)
) )
)
//Load user config // Load user config
val userConfig = val userConfig =
File(ApplicationRootDir, "server.conf").let { File(ApplicationRootDir, "server.conf").let {
ConfigFactory.parseFile(it) ConfigFactory.parseFile(it)
} }
val config = ConfigFactory.empty() val config = ConfigFactory.empty()
.withFallback(baseConfig) .withFallback(baseConfig)
.withFallback(userConfig) .withFallback(userConfig)
.withFallback(compatConfig) .withFallback(compatConfig)
.withFallback(serverConfig) .withFallback(serverConfig)
.resolve() .resolve()
// set log level early // set log level early
if (debugLogsEnabled(config)) { if (debugLogsEnabled(config)) {
@@ -20,7 +20,7 @@ abstract class ConfigModule(config: Config)
/** /**
* Abstract jvm-commandline-argument-overridable config module. * 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) val overridableConfig = SystemPropertyOverrideDelegate(config, moduleName)
} }
@@ -34,7 +34,7 @@ class SystemPropertyOverrideDelegate(val config: Config, val moduleName: String)
configValue.toString() configValue.toString()
) )
return when(T::class.simpleName) { return when (T::class.simpleName) {
"Int" -> combined.toInt() "Int" -> combined.toInt()
"Boolean" -> combined.toBoolean() "Boolean" -> combined.toBoolean()
// add more types as needed // 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 (KotlinLogging.logger(Logger.ROOT_LOGGER_NAME).underlyingLogger as ch.qos.logback.classic.Logger).level = level
} }
fun debugLogsEnabled(config: Config) fun debugLogsEnabled(config: Config) =
= System.getProperty("suwayomi.tachidesk.config.server.debugLogsEnabled", config.getString("server.debugLogsEnabled")).toBoolean() 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 import com.typesafe.config.Config
operator fun Config.get(key: String) = getString(key) 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 -1
View File
@@ -1,6 +1,6 @@
dependencies { dependencies {
// Android stub library // Android stub library
implementation(fileTree("lib/")) implementation("com.github.Suwayomi:android-jar:1.0.0")
// XML // XML
compileOnly("xmlpull:xmlpull:1.1.3.4a") compileOnly("xmlpull:xmlpull:1.1.3.4a")
-1
View File
@@ -1 +0,0 @@
android.jar
@@ -9,8 +9,10 @@ import android.content.Context
class PreferenceManager { class PreferenceManager {
companion object { companion object {
@JvmStatic @JvmStatic
fun getDefaultSharedPreferences(context: Context) fun getDefaultSharedPreferences(context: Context) =
= context.getSharedPreferences(context.applicationInfo.packageName, context.getSharedPreferences(
Context.MODE_PRIVATE)!! context.applicationInfo.packageName,
Context.MODE_PRIVATE
)!!
} }
} }
@@ -13,33 +13,49 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.Set; import java.util.Set;
public class MultiSelectListPreference extends DialogPreference { public class MultiSelectListPreference extends DialogPreference {
// reference: https://android.googlesource.com/platform/frameworks/support/+/996971f962fcd554339a7cb2859cef9ca89dbcb7/preference/preference/src/main/java/androidx/preference/MultiSelectListPreference.java
// Note: remove @JsonIgnore and implement methods if any extension ever uses these methods or the variables behind them // Note: remove @JsonIgnore and implement methods if any extension ever uses these methods or the variables behind them
public MultiSelectListPreference(Context context) { super(context); } private CharSequence[] entries;
private CharSequence[] entryValues;
public MultiSelectListPreference(Context context) {
super(context);
}
public void setEntries(CharSequence[] entries) {
this.entries = entries;
}
public CharSequence[] getEntries() {
return entries;
}
public void setEntryValues(CharSequence[] entryValues) {
this.entryValues = entryValues;
}
public CharSequence[] getEntryValues() {
return entryValues;
}
@JsonIgnore @JsonIgnore
public void setEntries(CharSequence[] entries) { throw new RuntimeException("Stub!"); } public void setValues(Set<String> values) {
throw new RuntimeException("Stub!");
}
@JsonIgnore @JsonIgnore
public CharSequence[] getEntries() { throw new RuntimeException("Stub!"); } public Set<String> getValues() {
throw new RuntimeException("Stub!");
}
@JsonIgnore public int findIndexOfValue(String value) {
public void setEntryValues(CharSequence[] entryValues) { throw new RuntimeException("Stub!"); } throw new RuntimeException("Stub!");
}
@JsonIgnore
public CharSequence[] getEntryValues() { throw new RuntimeException("Stub!"); }
@JsonIgnore
public void setValues(Set<String> values) { throw new RuntimeException("Stub!"); }
@JsonIgnore
public Set<String> getValues() { throw new RuntimeException("Stub!"); }
public int findIndexOfValue(String value) { throw new RuntimeException("Stub!"); }
/** Tachidesk specific API */ /** Tachidesk specific API */
@Override @Override
public String getDefaultValueType() { public String getDefaultValueType() {
return "Set"; return "Set<String>";
} }
} }
@@ -10,6 +10,7 @@ package androidx.preference;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.Set;
/** /**
* A minimal implementation of androidx.preference.Preference * A minimal implementation of androidx.preference.Preference
@@ -113,18 +114,22 @@ public class Preference {
} }
/** Tachidesk specific API */ /** Tachidesk specific API */
@SuppressWarnings("unchecked")
public Object getCurrentValue() { public Object getCurrentValue() {
switch (getDefaultValueType()) { switch (getDefaultValueType()) {
case "String": case "String":
return sharedPreferences.getString(key, (String)defaultValue); return sharedPreferences.getString(key, (String)defaultValue);
case "Boolean": case "Boolean":
return sharedPreferences.getBoolean(key, (Boolean)defaultValue); return sharedPreferences.getBoolean(key, (Boolean)defaultValue);
case "Set<String>":
return sharedPreferences.getStringSet(key, (Set<String>)defaultValue);
default: default:
throw new RuntimeException("Unsupported type"); throw new RuntimeException("Unsupported type");
} }
} }
/** Tachidesk specific API */ /** Tachidesk specific API */
@SuppressWarnings("unchecked")
public void saveNewValue(Object value) { public void saveNewValue(Object value) {
switch (getDefaultValueType()) { switch (getDefaultValueType()) {
case "String": case "String":
@@ -133,6 +138,9 @@ public class Preference {
case "Boolean": case "Boolean":
sharedPreferences.edit().putBoolean(key, (Boolean)value).apply(); sharedPreferences.edit().putBoolean(key, (Boolean)value).apply();
break; break;
case "Set<String>":
sharedPreferences.edit().putStringSet(key, (Set<String>)value).apply();
break;
default: default:
throw new RuntimeException("Unsupported type"); throw new RuntimeException("Unsupported type");
} }
@@ -5,4 +5,4 @@ import android.arch.persistence.db.framework.FrameworkSQLiteOpenHelperFactory
class RequerySQLiteOpenHelperFactory { class RequerySQLiteOpenHelperFactory {
fun create(configuration: SupportSQLiteOpenHelper.Configuration) = FrameworkSQLiteOpenHelperFactory().create(configuration) fun create(configuration: SupportSQLiteOpenHelper.Configuration) = FrameworkSQLiteOpenHelperFactory().create(configuration)
} }
@@ -14,4 +14,4 @@ class AndroidCompat {
application.attach(context) application.attach(context)
application.onCreate() application.onCreate()
} }
} }
@@ -14,7 +14,7 @@ class AndroidCompatInitializer {
fun init() { fun init() {
DI.global.addImport(AndroidCompatModule().create()) DI.global.addImport(AndroidCompatModule().create())
//Register config modules // Register config modules
GlobalConfigManager.registerModules( GlobalConfigManager.registerModules(
FilesConfigModule.register(GlobalConfigManager.config), FilesConfigModule.register(GlobalConfigManager.config),
ApplicationInfoConfigModule.register(GlobalConfigManager.config), ApplicationInfoConfigModule.register(GlobalConfigManager.config),
@@ -29,7 +29,7 @@ class AndroidCompatModule {
bind<PackageController>() with singleton { PackageController() } bind<PackageController>() with singleton { PackageController() }
//Context // Context
bind<CustomContext>() with singleton { CustomContext() } bind<CustomContext>() with singleton { CustomContext() }
bind<Context>() with singleton { bind<Context>() with singleton {
val context: Context by DI.global.instance<CustomContext>() val context: Context by DI.global.instance<CustomContext>()
@@ -13,7 +13,7 @@ class ApplicationInfoConfigModule(config: Config) : ConfigModule(config) {
val debug: Boolean by config val debug: Boolean by config
companion object { companion object {
fun register(config: Config) fun register(config: Config) =
= ApplicationInfoConfigModule(config.getConfig("android.app")) ApplicationInfoConfigModule(config.getConfig("android.app"))
} }
} }
@@ -9,26 +9,26 @@ import xyz.nulldev.ts.config.ConfigModule
*/ */
class FilesConfigModule(config: Config) : ConfigModule(config) { class FilesConfigModule(config: Config) : ConfigModule(config) {
val dataDir:String by config val dataDir: String by config
val filesDir:String by config val filesDir: String by config
val noBackupFilesDir:String by config val noBackupFilesDir: String by config
val externalFilesDirs: MutableList<String> by config val externalFilesDirs: MutableList<String> by config
val obbDirs: MutableList<String> by config val obbDirs: MutableList<String> by config
val cacheDir:String by config val cacheDir: String by config
val codeCacheDir:String by config val codeCacheDir: String by config
val externalCacheDirs: MutableList<String> by config val externalCacheDirs: MutableList<String> by config
val externalMediaDirs: MutableList<String> by config val externalMediaDirs: MutableList<String> by config
val rootDir:String by config val rootDir: String by config
val externalStorageDir:String by config val externalStorageDir: String by config
val downloadCacheDir:String by config val downloadCacheDir: String by config
val databasesDir: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 { companion object {
fun register(config: Config) fun register(config: Config) =
= FilesConfigModule(config.getConfig("android.files")) FilesConfigModule(config.getConfig("android.files"))
} }
} }
@@ -1,8 +1,8 @@
package xyz.nulldev.androidcompat.config package xyz.nulldev.androidcompat.config
import com.typesafe.config.Config import com.typesafe.config.Config
import xyz.nulldev.ts.config.ConfigModule
import io.github.config4k.getValue import io.github.config4k.getValue
import xyz.nulldev.ts.config.ConfigModule
class SystemConfigModule(val config: Config) : ConfigModule(config) { class SystemConfigModule(val config: Config) : ConfigModule(config) {
val isDebuggable: Boolean by 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") fun hasProperty(property: String) = config.hasPath("$propertyPrefix$property")
companion object { companion object {
fun register(config: Config) fun register(config: Config) =
= SystemConfigModule(config.getConfig("android.system")) SystemConfigModule(config.getConfig("android.system"))
} }
} }
@@ -29,7 +29,7 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
val parentMetadata = parent.metaData val parentMetadata = parent.metaData
val columnCount = parentMetadata.columnCount val columnCount = parentMetadata.columnCount
val columnLabels = (1 .. columnCount).map { val columnLabels = (1..columnCount).map {
parentMetadata.getColumnLabel(it) parentMetadata.getColumnLabel(it)
}.toTypedArray() }.toTypedArray()
@@ -41,10 +41,10 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
// How can we optimize this? // How can we optimize this?
// We need to fill the cache as the set is loaded // We need to fill the cache as the set is loaded
//Fill cache // Fill cache
while(parent.next()) { while (parent.next()) {
cachedContent += ResultSetEntry().apply { cachedContent += ResultSetEntry().apply {
for(i in 1 .. columnCount) for (i in 1..columnCount)
data += parent.getObject(i) data += parent.getObject(i)
} }
resultSetLength++ resultSetLength++
@@ -60,8 +60,8 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
} }
private fun internalMove(row: Int) { private fun internalMove(row: Int) {
if(cursor < 0) cursor = 0 if (cursor < 0) cursor = 0
else if(cursor > resultSetLength + 1) cursor = resultSetLength + 1 else if (cursor > resultSetLength + 1) cursor = resultSetLength + 1
else cursor = row else cursor = row
} }
@@ -75,10 +75,10 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
return obj(cachedFindColumn(column)) return obj(cachedFindColumn(column))
} }
private fun cachedFindColumn(column: String?) private fun cachedFindColumn(column: String?) =
= columnCache.getOrPut(column!!, { columnCache.getOrPut(column!!, {
findColumn(column) findColumn(column)
}) })
override fun getNClob(columnIndex: Int): NClob { override fun getNClob(columnIndex: Int): NClob {
return obj(columnIndex) as NClob return obj(columnIndex) as NClob
@@ -157,27 +157,27 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
} }
override fun getDate(columnIndex: Int): Date { override fun getDate(columnIndex: Int): Date {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getDate(columnLabel: String?): Date { override fun getDate(columnLabel: String?): Date {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getDate(columnIndex: Int, cal: Calendar?): Date { override fun getDate(columnIndex: Int, cal: Calendar?): Date {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getDate(columnLabel: String?, cal: Calendar?): Date { override fun getDate(columnLabel: String?, cal: Calendar?): Date {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun beforeFirst() { override fun beforeFirst() {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
@@ -202,12 +202,12 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
} }
override fun getBigDecimal(columnIndex: Int, scale: Int): BigDecimal { override fun getBigDecimal(columnIndex: Int, scale: Int): BigDecimal {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getBigDecimal(columnLabel: String?, scale: Int): BigDecimal { override fun getBigDecimal(columnLabel: String?, scale: Int): BigDecimal {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
@@ -236,22 +236,22 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
} }
override fun getTime(columnIndex: Int): Time { override fun getTime(columnIndex: Int): Time {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getTime(columnLabel: String?): Time { override fun getTime(columnLabel: String?): Time {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getTime(columnIndex: Int, cal: Calendar?): Time { override fun getTime(columnIndex: Int, cal: Calendar?): Time {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getTime(columnLabel: String?, cal: Calendar?): Time { override fun getTime(columnLabel: String?, cal: Calendar?): Time {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
@@ -272,28 +272,28 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
} }
override fun absolute(row: Int): Boolean { override fun absolute(row: Int): Boolean {
if(row > 0) { if (row > 0) {
internalMove(row) internalMove(row)
} else { } else {
last() last()
for(i in 1 .. row) for (i in 1..row)
previous() previous()
} }
return cursorValid() return cursorValid()
} }
override fun getSQLXML(columnIndex: Int): SQLXML? { override fun getSQLXML(columnIndex: Int): SQLXML? {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getSQLXML(columnLabel: String?): SQLXML? { override fun getSQLXML(columnLabel: String?): SQLXML? {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun <T : Any?> unwrap(iface: Class<T>?): T { override fun <T : Any?> unwrap(iface: Class<T>?): T {
if(thisIsWrapperFor(iface)) if (thisIsWrapperFor(iface))
return this as T return this as T
else else
return parent.unwrap(iface) return parent.unwrap(iface)
@@ -426,12 +426,12 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
} }
override fun getBlob(columnIndex: Int): Blob { override fun getBlob(columnIndex: Int): Blob {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getBlob(columnLabel: String?): Blob { override fun getBlob(columnLabel: String?): Blob {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
@@ -500,12 +500,12 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
} }
override fun getObject(columnIndex: Int, map: MutableMap<String, Class<*>>?): Any { override fun getObject(columnIndex: Int, map: MutableMap<String, Class<*>>?): Any {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getObject(columnLabel: String?, map: MutableMap<String, Class<*>>?): Any { override fun getObject(columnLabel: String?, map: MutableMap<String, Class<*>>?): Any {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
@@ -531,9 +531,9 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
} }
private fun castToLong(obj: Any?): Long { private fun castToLong(obj: Any?): Long {
if(obj == null) return 0 if (obj == null) return 0
else if(obj is Long) return obj else if (obj is Long) return obj
else if(obj is Number) return obj.toLong() else if (obj is Number) return obj.toLong()
else throw IllegalStateException("Object is not a long!") 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 { override fun getClob(columnIndex: Int): Clob {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getClob(columnLabel: String?): Clob { override fun getClob(columnLabel: String?): Clob {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
@@ -604,12 +604,12 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
} }
override fun getArray(columnIndex: Int): Array { override fun getArray(columnIndex: Int): Array {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getArray(columnLabel: String?): Array { override fun getArray(columnLabel: String?): Array {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
@@ -688,32 +688,32 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
} }
override fun getTimestamp(columnIndex: Int): Timestamp { override fun getTimestamp(columnIndex: Int): Timestamp {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getTimestamp(columnLabel: String?): Timestamp { override fun getTimestamp(columnLabel: String?): Timestamp {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getTimestamp(columnIndex: Int, cal: Calendar?): Timestamp { override fun getTimestamp(columnIndex: Int, cal: Calendar?): Timestamp {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getTimestamp(columnLabel: String?, cal: Calendar?): Timestamp { override fun getTimestamp(columnLabel: String?, cal: Calendar?): Timestamp {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getRef(columnIndex: Int): Ref { override fun getRef(columnIndex: Int): Ref {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getRef(columnLabel: String?): Ref { override fun getRef(columnLabel: String?): Ref {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
@@ -792,12 +792,12 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
} }
override fun getRowId(columnIndex: Int): RowId { override fun getRowId(columnIndex: Int): RowId {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
override fun getRowId(columnLabel: String?): RowId { override fun getRowId(columnLabel: String?): RowId {
//TODO Maybe? // TODO Maybe?
notImplemented() notImplemented()
} }
@@ -848,4 +848,4 @@ class ScrollableResultSet(val parent: ResultSet) : ResultSet by parent {
class ResultSetEntry { class ResultSetEntry {
val data = mutableListOf<Any?>() val data = mutableListOf<Any?>()
} }
} }
@@ -12,11 +12,11 @@ import com.russhwolf.settings.ExperimentalSettingsApi
import com.russhwolf.settings.ExperimentalSettingsImplementation import com.russhwolf.settings.ExperimentalSettingsImplementation
import com.russhwolf.settings.JvmPreferencesSettings import com.russhwolf.settings.JvmPreferencesSettings
import com.russhwolf.settings.serialization.decodeValue import com.russhwolf.settings.serialization.decodeValue
import com.russhwolf.settings.serialization.decodeValueOrNull
import com.russhwolf.settings.serialization.encodeValue import com.russhwolf.settings.serialization.encodeValue
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.SetSerializer import kotlinx.serialization.builtins.SetSerializer
import kotlinx.serialization.builtins.nullable
import kotlinx.serialization.builtins.serializer import kotlinx.serialization.builtins.serializer
import java.util.prefs.PreferenceChangeListener import java.util.prefs.PreferenceChangeListener
import java.util.prefs.Preferences import java.util.prefs.Preferences
@@ -40,13 +40,13 @@ class JavaSharedPreferences(key: String) : SharedPreferences {
} }
} }
override fun getStringSet(key: String, defValues: MutableSet<String>?): MutableSet<String>? { override fun getStringSet(key: String, defValues: Set<String>?): Set<String>? {
try { try {
return if (defValues != null) { return if (defValues != null) {
preferences.decodeValue(SetSerializer(String.serializer()).nullable, key, defValues) preferences.decodeValue(SetSerializer(String.serializer()), key, defValues)
} else { } else {
preferences.decodeValue(SetSerializer(String.serializer()).nullable, key, null) preferences.decodeValueOrNull(SetSerializer(String.serializer()), key)
}?.toMutableSet() }
} catch (e: SerializationException) { } catch (e: SerializationException) {
throw ClassCastException("$key was not a StringSet") throw ClassCastException("$key was not a StringSet")
} }
@@ -174,4 +174,4 @@ class JavaSharedPreferences(key: String) : SharedPreferences {
javaPreferences.removeNode() javaPreferences.removeNode()
return true return true
} }
} }
@@ -14,8 +14,6 @@ import java.io.File
import javax.imageio.ImageIO import javax.imageio.ImageIO
import javax.xml.parsers.DocumentBuilderFactory import javax.xml.parsers.DocumentBuilderFactory
data class InstalledPackage(val root: File) { data class InstalledPackage(val root: File) {
val apk = File(root, "package.apk") val apk = File(root, "package.apk")
val jar = File(root, "translated.jar") val jar = File(root, "translated.jar")
@@ -40,20 +38,24 @@ data class InstalledPackage(val root: File) {
}?.filter { }?.filter {
it.tagName == "meta-data" it.tagName == "meta-data"
}?.map { }?.map {
putString(it.attributes.getNamedItem("android:name").nodeValue, putString(
it.attributes.getNamedItem("android:value").nodeValue) it.attributes.getNamedItem("android:name").nodeValue,
it.attributes.getNamedItem("android:value").nodeValue
)
} }
} }
it.signatures = (parsed.apkSingers.flatMap { it.certificateMetas } it.signatures = (
/*+ parsed.apkV2Singers.flatMap { it.certificateMetas }*/) // Blocked by: https://github.com/hsiafan/apk-parser/issues/72 parsed.apkSingers.flatMap { it.certificateMetas }
.map { Signature(it.data) }.toTypedArray() /*+ parsed.apkV2Singers.flatMap { it.certificateMetas }*/
) // Blocked by: https://github.com/hsiafan/apk-parser/issues/72
.map { Signature(it.data) }.toTypedArray()
} }
fun verify(): Boolean { fun verify(): Boolean {
val res = ApkVerifier.Builder(apk) val res = ApkVerifier.Builder(apk)
.build() .build()
.verify() .verify()
return res.isVerified return res.isVerified
} }
@@ -69,7 +71,7 @@ data class InstalledPackage(val root: File) {
}.sortedByDescending { it.width * it.height }.firstOrNull() ?: return }.sortedByDescending { it.width * it.height }.firstOrNull() ?: return
ImageIO.write(read, "png", icon) ImageIO.write(read, "png", icon)
} catch(e: Exception) { } catch (e: Exception) {
icon.delete() icon.delete()
} }
} }
@@ -77,7 +79,7 @@ data class InstalledPackage(val root: File) {
fun writeJar() { fun writeJar() {
try { try {
Dex2jar.from(apk).to(jar.toPath()) Dex2jar.from(apk).to(jar.toPath())
} catch(e: Exception) { } catch (e: Exception) {
jar.delete() jar.delete()
} }
} }
@@ -92,4 +94,4 @@ data class InstalledPackage(val root: File) {
return out return out
} }
} }
} }
@@ -48,7 +48,7 @@ class PackageController {
if (!installed.jar.exists()) { if (!installed.jar.exists()) {
throw IllegalStateException("Failed to translate APK dex!") throw IllegalStateException("Failed to translate APK dex!")
} }
} catch(t: Throwable) { } catch (t: Throwable) {
root.deleteRecursively() root.deleteRecursively()
throw t throw t
} }
@@ -63,7 +63,7 @@ class PackageController {
} }
fun deletePackage(pack: InstalledPackage) { 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 val packageName = pack.info.packageName
pack.root.deleteRecursively() pack.root.deleteRecursively()
@@ -74,7 +74,7 @@ class PackageController {
fun findPackage(packageName: String): InstalledPackage? { fun findPackage(packageName: String): InstalledPackage? {
val file = File(androidFiles.packagesDir, packageName) val file = File(androidFiles.packagesDir, packageName)
return if(file.exists()) return if (file.exists())
InstalledPackage(file) InstalledPackage(file)
else else
null null
@@ -84,4 +84,4 @@ class PackageController {
val pkgName = ApkParsers.getMetaInfo(apkFile).packageName val pkgName = ApkParsers.getMetaInfo(apkFile).packageName
return findPackage(pkgName)?.jar return findPackage(pkgName)?.jar
} }
} }
@@ -24,4 +24,4 @@ fun ApkMeta.toPackageInfo(apk: File): PackageInfo {
sourceDir = apk.absolutePath sourceDir = apk.absolutePath
} }
} }
} }
@@ -24,4 +24,4 @@ interface Resource {
fun getType(): Class<out Resource> fun getType(): Class<out Resource>
fun getValue(): Any? fun getValue(): Any?
} }
@@ -27,10 +27,10 @@ class ServiceSupport {
runningServices[name] = service runningServices[name] = service
//Setup service // Setup service
thread { thread {
callOnCreate(service) callOnCreate(service)
//TODO Handle more complex cases // TODO Handle more complex cases
service.onStartCommand(intent, 0, 0) service.onStartCommand(intent, 0, 0)
} }
} }
@@ -43,7 +43,7 @@ class ServiceSupport {
fun stopService(name: String) { fun stopService(name: String) {
logger.debug { "Stopping service: $name" } logger.debug { "Stopping service: $name" }
val service = runningServices.remove(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" } logger.warn { "An attempt was made to stop a service that is not running: $name" }
} else { } else {
thread { thread {
@@ -63,6 +63,6 @@ class ServiceSupport {
fun serviceInstanceFromClass(className: String): Service { fun serviceInstanceFromClass(className: String): Service {
val clazzObj = Class.forName(className) val clazzObj = Class.forName(className)
return clazzObj.getDeclaredConstructor().newInstance() as? Service 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 @JvmStatic
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun <T : Any> instance(type: Class<T>, kodein: DI? = null): T { fun <T : Any> instance(type: Class<T>, kodein: DI? = null): T {
return when(type) { return when (type) {
AndroidFiles::class.java -> { AndroidFiles::class.java -> {
val instance: AndroidFiles by (kodein ?: kodein()).instance() val instance: AndroidFiles by (kodein ?: kodein()).instance()
instance as T instance as T
@@ -64,5 +64,4 @@ object KodeinGlobalHelper {
fun <T : Any> instance(type: Class<T>): T { fun <T : Any> instance(type: Class<T>): T {
return instance(type, null) return instance(type, null)
} }
} }
+3 -20
View File
@@ -1,28 +1,11 @@
# Server: v0.X.Y-rXXX + WebUI: rXXX # Server: v0.X.Y-next + WebUI: rXXX
## TL;DR ## TL;DR
<!-- TODO: fill before release -->
## Tachidesk-Server
### Public API
#### Non-breaking changes
- N/A - N/A
#### Breaking changes ## Tachidesk-Server Changelog
- N/A - N/A
#### Bug fixes ## Tachidesk-WebUI Changelog
- N/A
### Private API
- N/A - N/A
## Tachidesk-WebUI
#### Visible changes
- N/A
#### Bug fixes
- N/A
#### Internal changes
- N/A
+178 -3
View File
@@ -1,3 +1,177 @@
# Server: v0.6.0 + WebUI: r893
## TL;DR
- WebUI design went through a whole lot of changes, including
- Got rid of hamburger menu, now we have a custom mobile navbar
- Unread and Download count badges
- Back button so better electron experience
- There's a whole lot more that I'm too lazy to explore.
- Completely removed anime support
- Fixed category reordering
- Added support for search filters(Server side only)
- Added support for updating library(Server side only)
- A bunch of API breaking changes(hence why bumping to v0.6.0)!
## Tachidesk-Server Changelog
- (r996) cleanup (by @AriaMoradi)
- (r999) better cleaning algorithm (by @AriaMoradi)
- (r1007) remove anime support (by @AriaMoradi)
- (r1009) Fix tests ([#226](https://github.com/Suwayomi/Tachidesk-Server/pull/226) by @ntbm)
- (r1010) Expose unread and download count of Manga in category api ([#227](https://github.com/Suwayomi/Tachidesk-Server/pull/227) by @ntbm)
- (r1011) add Cache Header to Thumbnail Response for improved library performance ([#228](https://github.com/Suwayomi/Tachidesk-Server/pull/228) by @ntbm)
- (r1013) Fix unread and download counts casing ([#230](https://github.com/Suwayomi/Tachidesk-Server/pull/230) by @Syer10)
- (r1014) Fix broken test ([#231](https://github.com/Suwayomi/Tachidesk-Server/pull/231) by @ntbm)
- (r1016) Fix category reorder Endpoint. Added Test for Category Reorder ([#232](https://github.com/Suwayomi/Tachidesk-Server/pull/232) by @ntbm)
- (r1017) change windows bundle names (by @AriaMoradi)
- (r1018) improve tests (by @AriaMoradi)
- (r1019) allow injecting Sources (by @AriaMoradi)
- (r1020) update (by @AriaMoradi)
- (r1021) fix credit (by @AriaMoradi)
- (r1022) cleanup (by @AriaMoradi)
- (r1023) refactor (by @AriaMoradi)
- (r1024) refactor (by @AriaMoradi)
- (r1025) implement Source Filters (by @AriaMoradi)
- (r1026) ignore build artifacts generated by teting (by @AriaMoradi)
- (r1027) convert request type (by @AriaMoradi)
- (r1028) Update CONTRIBUTING.md (by @AriaMoradi)
- (r1029) stop supporting zero based image storage ([#242](https://github.com/Suwayomi/src/pull/242) by @AriaMoradi)
- (r1030) add manga data to download queue object ([#244](https://github.com/Suwayomi/src/pull/244) by @AriaMoradi)
- (r1031) Fix Manga Meta, add Manga Meta test ([#245](https://github.com/Suwayomi/src/pull/245) by @Syer10)
- (r1032) add pagination to recentChapters ([#246](https://github.com/Suwayomi/src/pull/246) by @AriaMoradi)
- (r1033) update (by @AriaMoradi)
- (r1034) Implement Update of Library/Category ([#235](https://github.com/Suwayomi/src/pull/235) by @ntbm)
- (r1035) update (by @AriaMoradi)
- (r1036) Mention the existence of Mahor's Tachidesk-GTK (by @AriaMoradi)
- (r1037) Add a Kotlin DSL for endpoint documentation ([#249](https://github.com/Suwayomi/Tachidesk-Server/pull/249) by @Syer10)
- (r1038) update (by @AriaMoradi)
- (r1039) update (by @AriaMoradi)
- (r1040) cleanup directory names ([#251](https://github.com/Suwayomi/Tachidesk-Server/pull/251) by @AriaMoradi)
- (r1041) Fix first page not being detected correctly ([#253](https://github.com/Suwayomi/Tachidesk-Server/pull/253) by @AriaMoradi)
- (r1042) Update README.md (by @AriaMoradi)
- (r1043) Update README.md (by @AriaMoradi)
- (r1044) migrate application directories ([#255](https://github.com/Suwayomi/Tachidesk-Server/pull/255) by @AriaMoradi)
- (r1045) add support for MultiSelectListPreference ([#258](https://github.com/Suwayomi/Tachidesk-Server/pull/258) by @AriaMoradi)
- (r1046) empty searchTerm support ([#259](https://github.com/Suwayomi/Tachidesk-Server/pull/259) by @AriaMoradi)
## Tachidesk-WebUI
- (r821) add Permanent sidebar for desktop widths([#46](https://github.com/Suwayomi/Tachidesk-WebUI/pull/46) by @abhijeetChawla)
- (r822) Fix Local Source being missing (by @AriaMoradi)
- (r823) fix the ugliness of bare messages (by @AriaMoradi)
- (r824) add pull request template (by @AriaMoradi)
- (r825) add Unread badges ([#48](https://github.com/Suwayomi/Tachidesk-WebUI/pull/48) by @ntbm)
- (r826) Back button implementation ([#47](https://github.com/Suwayomi/Tachidesk-WebUI/pull/47) by @abhijeetChawla)
- (r827) remove redundant '/manga' prefix from paths (by @AriaMoradi)
- (r828) refactor (by @AriaMoradi)
- (r829) put Sources and Extensions in the same screen (by @AriaMoradi)
- (r830) Set Fallback Image for broken Thumbnails ([#50](https://github.com/Suwayomi/Tachidesk-WebUI/pull/50) by @ntbm)
- (r833) Apply Api changes for unread badges ([#52](https://github.com/Suwayomi/Tachidesk-WebUI/pull/52) by @ntbm)
- (r834) add EmptyView to DownloadQueue, refactro strings ([#53](https://github.com/Suwayomi/Tachidesk-WebUI/pull/53) by @abhijeetChawla)
- (r835) Bottom navbar for mobile ([#51](https://github.com/Suwayomi/Tachidesk-WebUI/pull/51) by @abhijeetChawla)
- (r836) Implement Unread Filter for Library ([#54](https://github.com/Suwayomi/Tachidesk-WebUI/pull/54) by @ntbm)
- (r837) fix navbar broken logic (by @AriaMoradi)
- (r838) fix navbar (by @AriaMoradi)
- (r839) refactor (by @AriaMoradi)
- (r840) refactor (by @AriaMoradi)
- (r841) refactor (by @AriaMoradi)
- (r842) show different NavbarItems depending on device width (by @AriaMoradi)
- (r843) remove text decoration (by @AriaMoradi)
- (r844) fancy icon based on if path selected (by @AriaMoradi)
- (r845) custom Extension icon, google's version is shit (by @AriaMoradi)
- (r846) refactor (by @AriaMoradi)
- (r848) move info (by @AriaMoradi)
- (r849) add Search to Library ([#55](https://github.com/Suwayomi/Tachidesk-WebUI/pull/55) by @ntbm)
- (r850) add aspect ratio to the manga card. ([#56](https://github.com/Suwayomi/Tachidesk-WebUI/pull/56) by @abhijeetChawla)
- (r851) better wording (by @AriaMoradi)
- (r852) reorder nav buttons (by @AriaMoradi)
- (r853) nicer gradient (by @AriaMoradi)
- (r854) refactor MangaCard (by @AriaMoradi)
- (r855) closes #58 (by @AriaMoradi
- (r856) Add Resume Reading FAB Manga screen ([#59](https://github.com/Suwayomi/Tachidesk-WebUI/pull/59) by @abhijeetChawla)
- (r857) add filter and badge for `downloadCount` ([#62](https://github.com/Suwayomi/Tachidesk-WebUI/pull/62) by @abhijeetChawla)
- (r858) add issue template (by @AriaMoradi)
- (r859) Change color of navbar in light mode ([#65](https://github.com/Suwayomi/Tachidesk-WebUI/pull/65) by @abhijeetChawla)
- (r860) fix manga FAB margins ([#66](https://github.com/Suwayomi/Tachidesk-WebUI/pull/66) by @AriaMoradi)
- (r861) remove extra scrollbar on mobile ([#67](https://github.com/Suwayomi/Tachidesk-WebUI/pull/67) by @AriaMoradi)
- (r862) Fix Bad messages in Library Appbar search ([#70](https://github.com/Suwayomi/Tachidesk-WebUI/pull/70) by @ntbm)
- (r863) ban the style prop (by @AriaMoradi)
- (r864) Updates pagination update ([#68](https://github.com/Suwayomi/Tachidesk-WebUI/pull/68) by @AriaMoradi)
- (r865) make the whole chapter card into a button ([#73](https://github.com/Suwayomi/Tachidesk-WebUI/pull/73) by @AriaMoradi)
- (r866) fix chapter actions not working if manga is not fetched online ([#74](https://github.com/Suwayomi/Tachidesk-WebUI/pull/74) by @AriaMoradi)
- (r867) migrate some components to Mui5 new styling system ([#72](https://github.com/Suwayomi/Tachidesk-WebUI/pull/72) by @abhijeetChawla)
- (r868) load first page on read manga ([#76](https://github.com/Suwayomi/Tachidesk-WebUI/pull/76) by @AriaMoradi)
- (r869) Revert "migrate some components to Mui5 new styling system ([#72](https://github.com/Suwayomi/Tachidesk-WebUI/pull/72))" (by @AriaMoradi)
- (r870) migrate Backup to Mui 5 ([#106](https://github.com/Suwayomi/Tachidesk-WebUI/pull/106) by @AriaMoradi)
- (r871) migrate EmptyView to Mui 5 ([#95](https://github.com/Suwayomi/Tachidesk-WebUI/pull/95) by @AriaMoradi)
- (r872) migrate CategorySelect to Mui 5 ([#85](https://github.com/Suwayomi/Tachidesk-WebUI/pull/85) by @AriaMoradi)
- (r873) migrate LibraryOptions to Mui 5 ([#83](https://github.com/Suwayomi/Tachidesk-WebUI/pull/83) by @AriaMoradi)
- (r874) migrate ChapterCard.tsx to Mui 5 ([#80](https://github.com/Suwayomi/Tachidesk-WebUI/pull/80) by @AriaMoradi)
- (r875) migrate App.tsx to Mui 5 ([#79](https://github.com/Suwayomi/Tachidesk-WebUI/pull/79) by @AriaMoradi)
- (r876) migrate SourceConfigure to Mui 5 ([#103](https://github.com/Suwayomi/Tachidesk-WebUI/pull/103) by @AriaMoradi)
- (r877) migrate Settings to Mui 5 ([#102](https://github.com/Suwayomi/Tachidesk-WebUI/pull/102) by @AriaMoradi)
- (r878) migrate Updates to Mui 5 ([#104](https://github.com/Suwayomi/Tachidesk-WebUI/pull/104) by @AriaMoradi)
- (r879) Save tabs number in Url to persist tab when go to other paths ([#78](https://github.com/Suwayomi/Tachidesk-WebUI/pull/78) by @abhijeetChawla)
- (r880) migrate LangSelect to Mui 5 ([#86](https://github.com/Suwayomi/Tachidesk-WebUI/pull/86) by @AriaMoradi)
- (r881) migrate ExtensionCard.tsx to Mui 5 ([#81](https://github.com/Suwayomi/Tachidesk-WebUI/pull/81) by @AriaMoradi)
- (r882) migrate SingleSearch to Mui 5 ([#101](https://github.com/Suwayomi/Tachidesk-WebUI/pull/101) by @AriaMoradi)
- (r883) migrate LoadingPlaceholder to Mui 5 ([#96](https://github.com/Suwayomi/Tachidesk-WebUI/pull/96) by @AriaMoradi)
- (r884) migrate About to Mui 5 ([#105](https://github.com/Suwayomi/Tachidesk-WebUI/pull/105) by @AriaMoradi)
- (r885) migrate SourceCard to Mui 5 ([#82](https://github.com/Suwayomi/Tachidesk-WebUI/pull/82) by @AriaMoradi)
- (r886) migrate Manga to Mui 5 ([#99](https://github.com/Suwayomi/Tachidesk-WebUI/pull/99) by @AriaMoradi)
- (r887) migrate Browse to Mui 5 ([#98](https://github.com/Suwayomi/Tachidesk-WebUI/pull/98) by @AriaMoradi)
- (r888) migrate DesktopSideBar to Mui 5 ([#87](https://github.com/Suwayomi/Tachidesk-WebUI/pull/87) by @AriaMoradi)
- (r889) cleanup library ([#107](https://github.com/Suwayomi/Tachidesk-WebUI/pull/107) by @AriaMoradi)
- (r890) support for new searchTerm (by @AriaMoradi)
- (r891) Revert "support for new searchTerm" (by @AriaMoradi)
- (r892) add support for emptySearch ([#109](https://github.com/Suwayomi/Tachidesk-WebUI/pull/109) by @AriaMoradi)
- (r893) add support for MultiSelectListPreference ([#108](https://github.com/Suwayomi/Tachidesk-WebUI/pull/108) by @AriaMoradi)
# Server: v0.5.4 + WebUI: r820
## TL;DR
- Fixed ReadComicOnline, Toonily and possibly other sources not working
- Backup and Restore now includes Updates tab data
- Removed Anime support from WebUI, Anime support will also be removed from Tachidesk-Server in a future update
## Tachidesk-Server Changelog
- (r973) convert android.jar lib to a maven repo
- (r978) mimic Tachiyomi's behaviour more closely, fixes ReadComicOnline (EN)
- (r980) fix export chapter ordering, include new props in backup
- (r982) remove isNsfw annotation detection
- (r984) use correct time conversion units when doing backups
- (r989) Support using a CatalogueSource instead of only HttpSources ([#219](https://github.com/Suwayomi/Tachidesk-Server/pull/219) by @Syer10)
- (r991) Use a custom task to run electron ([#220](https://github.com/Suwayomi/Tachidesk-Server/pull/220) by @Syer10)
## Tachidesk-WebUI Changelog
- (r810) fix wrong strings in set Server Address dialog, fixes [#39](https://github.com/Suwayomi/Tachidesk-WebUI/issues/39)
- (r811) fix chapterFetch loop
- (r812) fix overlapping requests
- (r813) fix typo
- (r814) Better portrait support ([#41](https://github.com/Suwayomi/Tachidesk-WebUI/issues/41) by @minhe7735)
- (r815) fixes Reader navbar colors when in light mode ([#43](https://github.com/Suwayomi/Tachidesk-WebUI/issues/43) by @abhijeetChawla)
- (r816) default languages cleanup, force Local source enabled
- (r817) force Local source at LangSelect
- (r818) rename ExtensionLangSelect: generic name for generic use
- (r819) don't show anime anymore
- (r820) Remove Anime support
# Server: v0.5.3 + WebUI: r809
## TL;DR
- added support for a equivalent page to Tachiyomi's Updates tab
- fix launchers not working on macOS M1/arm64
## 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 # Server: v0.5.2 + WebUI: r807
## TL;DR ## TL;DR
- Fixed Local source not working on Windows - Fixed Local source not working on Windows
@@ -12,11 +186,12 @@
- N/A - N/A
#### Bug fixes #### Bug fixes
- N/A - (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 ### Private API
- (r942) Gradle Updates ([#199](https://github.com/Suwayomi/Tachidesk-WebUI/pull/199) by @Syer10) - (r941) Update BytecodeEditor to use Java NIO Paths ([#200](https://github.com/Suwayomi/Tachidesk-Server/pull/200) by @Syer10)
- (r941) Update BytecodeEditor to use Java NIO Paths ([#200](https://github.com/Suwayomi/Tachidesk-WebUI/pull/200) by @Syer10) - (r942) Gradle Updates ([#199](https://github.com/Suwayomi/Tachidesk-Server/pull/199) by @Syer10)
## Tachidesk-WebUI ## Tachidesk-WebUI
+22 -5
View File
@@ -2,10 +2,20 @@
## Where should I start? ## Where should I start?
Checkout [This Kanban Board](https://github.com/Suwayomi/Tachidesk/projects/1) to see the rough development roadmap. Checkout [This Kanban Board](https://github.com/Suwayomi/Tachidesk/projects/1) to see the rough development roadmap.
**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 1:** Notify the developers on [Suwayomi discord](https://discord.gg/DDZdqZWaHA) (#tachidesk-server and #tachidesk-webui channels) 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). **Note 2:** Your pull request will be squashed into a single commit.
### Project goals and vision
- Porting Tachiyomi and covering it's features
- Syncing with Tachiyomi, [main issue](https://github.com/Suwayomi/Tachidesk-Server/issues/159)
- Generally rejecting features that Tachiyomi(main app) doesn't have,
- Unless it's something that makes sense for desktop sizes or desktop form factor (keyboard + mouse)
- Additional/crazy features can go in forks and alternative clients
- [Tachidesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI) should
- be responsive
- support both desktop and mobile form factors well
## How does Tachidesk-Server work? ## How does Tachidesk-Server work?
This project has two components: 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. 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.
@@ -22,9 +32,6 @@ This structure is chosen to
You need these software packages installed in order to build the project 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) - 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.
### building the full-blown jar (Tachidesk-Server + Tachidesk-WebUI bundle) ### 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`. Run `./gradlew server:downloadWebUI server:shadowJar`, the resulting built jar file will be `server/build/Tachidesk-Server-vX.Y.Z-rxxx.jar`.
@@ -37,3 +44,13 @@ First Build the jar, then cd into the `scripts` directory and run `./windows-bun
## Running in development mode ## Running in development mode
run `./gradlew :server:run --stacktrace` to run the server run `./gradlew :server:run --stacktrace` to run the server
## Running tests
run `./gradlew :server:test` to execute all tests
to test a specific class run `./gradlew :server:test --tests <package.with.classname>`
## Building the android-jar maven repository
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,
then use `AndroidCompat/lib/android.jar` to manually create a maven repository inside the `android-jar` git branch.
Update the dependency declaration afterwards.
+80 -35
View File
@@ -3,60 +3,72 @@
|-------|----------|---------|---------| |-------|----------|---------|---------|
| ![CI](https://github.com/Suwayomi/Tachidesk/actions/workflows/build_push.yml/badge.svg) | [![stable release](https://img.shields.io/github/release/Suwayomi/Tachidesk.svg?maxAge=3600&label=download)](https://github.com/Suwayomi/Tachidesk/releases) | [![preview](https://img.shields.io/badge/dynamic/json?url=https://github.com/Suwayomi/Tachidesk-preview/raw/main/index.json&label=download&query=$.latest&color=blue)](https://github.com/Suwayomi/Tachidesk-preview/releases/latest) | [![Discord](https://img.shields.io/discord/801021177333940224.svg?label=discord&labelColor=7289da&color=2c2f33&style=flat)](https://discord.gg/DDZdqZWaHA) | | ![CI](https://github.com/Suwayomi/Tachidesk/actions/workflows/build_push.yml/badge.svg) | [![stable release](https://img.shields.io/github/release/Suwayomi/Tachidesk.svg?maxAge=3600&label=download)](https://github.com/Suwayomi/Tachidesk/releases) | [![preview](https://img.shields.io/badge/dynamic/json?url=https://github.com/Suwayomi/Tachidesk-preview/raw/main/index.json&label=download&query=$.latest&color=blue)](https://github.com/Suwayomi/Tachidesk-preview/releases/latest) | [![Discord](https://img.shields.io/discord/801021177333940224.svg?label=discord&labelColor=7289da&color=2c2f33&style=flat)](https://discord.gg/DDZdqZWaHA) |
# Tachidesk-Server is a server app! You may not want to Download Tachidesk-Server directly. ## Table of Content
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. - [What is Tachidesk?](#what-is-tachidesk)
- [Tachidesk client projects](#tachidesk-client-projects)
* [Is this application usable? Should I test it?](#is-this-application-usable-should-i-test-it)
- [Downloading and Running the app](#downloading-and-running-the-app)
* [Using Operating System Specific Bundles](#using-operating-system-specific-bundles)
- [Launcher Scripts](#launcher-scripts)
+ [Windows](#windows)
+ [macOS](#macos)
+ [GNU/Linux](#gnulinux)
* [Other methods of getting Tachidesk](#other-methods-of-getting-tachidesk)
+ [Arch Linux](#arch-linux)
+ [Ubuntu-based distributions](#ubuntu-based-distributions)
+ [Docker](#docker)
* [Advanced Methods](#advanced-methods)
+ [Running the jar release directly](#running-the-jar-release-directly)
+ [Using Tachidesk Remotely](#using-tachidesk-remotely)
- [Syncing With Tachiyomi](#syncing-with-tachiyomi)
- [Troubleshooting and Support](#troubleshooting-and-support)
- [Contributing and Technical info](#contributing-and-technical-info)
- [Credit](#credit)
- [License](#license)
<!-- Generated with https://ecotrust-canada.github.io/markdown-toc/ -->
Here's a list of known clients/user interfaces for Tachidesk-Server: # What is Tachidesk?
- [Tachidesk-JUI](https://github.com/Suwayomi/Tachidesk-JUI): The "official" front-end for Tachidesk-Server, A native desktop Application.
- [Tachidesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI): The web/electrion front-end that Tachidesk-Server is traditionally shipped with.
- [Tachidesk-qtui](https://github.com/Suwayomi/Tachidesk-qtui): A C++/Qt front-end for mobile devices(Android/linux), in super early stage of development.
- [Equinox](https://github.com/Suwayomi/Equinox): A web user interface made with Vue.js, in super early stage of development.
# What is Tachidesk then?
<img src="https://github.com/Suwayomi/Tachidesk/raw/master/server/src/main/resources/icon/faviconlogo.png" alt="drawing" width="200"/> <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/). A free and open source manga reader server that runs extensions built for [Tachiyomi](https://tachiyomi.org/).
Tachidesk is an independent Tachiyomi compatible software and is **not a Fork of** Tachiyomi. Tachidesk is an independent Tachiyomi compatible software and is **not a Fork of** Tachiyomi.
`Tachidesk` is a general term used to describe the combination of Tachidesk-Server(this project) and one of our clients.
Think of it roughly like the concept of "distribution" in GNU/Linux distributions, in which Linux(Tachidesk-Server) is the kernel and the difference is which desktop environment(Tachidesk client) you get with it.
Tachidesk-Server is as multi-platform as you can get. Any platform that runs java and/or has a modern browser can run it. This includes Windows, Linux, macOS, chrome OS, etc. Follow [Downloading and Running the app](#downloading-and-running-the-app) for installation instructions. Tachidesk-Server is as multi-platform as you can get. Any platform that runs java and/or has a modern browser can run it. This includes Windows, Linux, macOS, chrome OS, etc. Follow [Downloading and Running the app](#downloading-and-running-the-app) for installation instructions.
Ability to read and write Tachiyomi compatible backups and syncing is a planned feature. Ability to sync with Tachiyomi is a planned feature.
# Tachidesk client projects
**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-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI): The web/ElectronJS front-end that Tachidesk-Server is traditionally shipped with. Usually gets new features faster.
- [Tachidesk-JUI](https://github.com/Suwayomi/Tachidesk-JUI): The native desktop front-end for Tachidesk-Server. Currently the most advanced. Downlading Tachidesk-JUI is not recommened for now, the current release is getting obsolete, a new version is to be released soon(TM).
- [Tachidesk-qtui](https://github.com/Suwayomi/Tachidesk-qtui): A C++/Qt front-end for mobile devices(Android/linux), in super early stage of development.
- [Tachidesk-GTK](https://github.com/mahor1221/Tachidesk-GTK): A native Rust/GTK desktop client, 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. Seemingly abandoned.
## Is this application usable? Should I test it? ## Is this application usable? Should I test it?
Here is a list of current features: Here is a list of current features:
- From Tachiyomi - Installing and executing Tachiyomi's Extensions, So you'll get the same sources
- Installing and executing Tachiyomi's Extensions, So you'll get the same sources - A library to save your mangas and categories to put them into
- A library to save your mangas and categories to put them into - Searching and browsing installed sources
- Searching and browsing installed sources - Ability to download Manga for offline read
- Ability to download Manga for offline read - Backup and restore support powered by Tachiyomi-compatible Backups
- Backup and restore support powered by Tachiyomi Backups - Viewing latest updated chapters.
- From Aniyomi
- Installing and executing Aniyomi's Extensions
- Searching and browsing installed sources.
- Viewing an anime and it's episodes
**Note:** These are capabilities of Tachidesk-Server, the actual working support is provided by each front-end app, checkout their respective readme for more info. **Note:** These are capabilities of Tachidesk-Server, the actual working support is provided by each front-end app, checkout their respective readme for more info.
**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 # 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).
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.
## Using Operating System Specific Bundles ## 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. To facilitate the use of Tachidesk we provide bundle releases that include The Java Runtime Environment, ElectronJS and 3 Tachidesk Launcher Scripts.
If a bundle for your operating system or cpu architecture is not provided then refer to [Advanced Methods](#advanced-methods)
#### Launcher Scripts #### Launcher Scripts
- `Tachidesk Electron Launcher`: Launches Tachidesk inside Electron as a desktop applicaton - `Tachidesk Electron Launcher`: Launches Tachidesk inside Electron as a desktop applicaton
- `Tachidesk Browser Launcher`: Launches Tachidesk in a browser window - `Tachidesk Browser Launcher`: Launches Tachidesk in a browser window
@@ -86,6 +98,14 @@ You can install Tachidesk from the AUR
yay -S tachidesk yay -S tachidesk
``` ```
### Ubuntu-based distributions
More information can be found on the [PPA's page](https://launchpad.net/~suwayomi/+archive/ubuntu/tachidesk).
```
sudo add-apt-repository ppa:suwayomi/tachidesk
sudo apt update
sudo apt install tachidesk
```
### Docker ### Docker
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. 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.
@@ -98,8 +118,33 @@ Run Container from the command line:
$ docker run -p 4567:4567 ghcr.io/suwayomi/tachidesk $ docker run -p 4567:4567 ghcr.io/suwayomi/tachidesk
``` ```
## Advanced Methods
### Running the jar release directly
In order to run the app you need the following:
- The jar release of Tachidesk-Server
- The Java Runtime Environment(JRE) 8 or newer
- A Browser like Google Chrome, Firefox, Edge, etc.
- ElectronJS (optional)
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).
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-rxxxx.jar` from a Terminal/Command Prompt window to run the app which will open a new browser window automatically.
### Using Tachidesk Remotely ### Using Tachidesk Remotely
You can run Tachidesk on your computer or a server and connect to it remotely through the web interface with a web browser on any device including a mobile or tablet or even your smart TV!, this method of using Tachidesk is only recommended if you are a power user and know what you are doing. You can run Tachidesk on your computer or a server and connect to it remotely through one of our clients or the bundled web interface with a web browser. This method of using Tachidesk is requires a bit of networking/firewall/port forwarding/server configuration/etc. knowledge on your side, if you can run a Minecraft server and configure it, then you are good to go.
Check out [this wiki page](https://github.com/Suwayomi/Tachidesk-Server/wiki/Configuring-Tachidesk-Server) for a guide on configuring Tachidesk-Server.
If you face issues with your setup then we are happy to provide help, just join our discord server(a discord badge is on the top of the page, you are just a click clack away!).
## Syncing With Tachiyomi
### The Tachidesk extension
- You can install the `Tachidesk` extension inside tachiyomi.
- The extension will load Tachidesk library.
- By manipulating filters you can browse your categories.
### Other methods
Checkout [this issue](https://github.com/Suwayomi/Tachidesk-Server/issues/159) for tracking progress.
## Troubleshooting and Support ## Troubleshooting and Support
See [this troubleshooting wiki page](https://github.com/Suwayomi/Tachidesk/wiki/Troubleshooting). See [this troubleshooting wiki page](https://github.com/Suwayomi/Tachidesk/wiki/Troubleshooting).
+4
View File
@@ -6,6 +6,7 @@ plugins {
kotlin("jvm") version kotlinVersion kotlin("jvm") version kotlinVersion
kotlin("plugin.serialization") version kotlinVersion kotlin("plugin.serialization") version kotlinVersion
id("org.jmailen.kotlinter") version "3.6.0" id("org.jmailen.kotlinter") version "3.6.0"
id("com.github.gmazzo.buildconfig") version "3.0.3" apply false
} }
allprojects { allprojects {
@@ -17,6 +18,7 @@ allprojects {
mavenCentral() mavenCentral()
google() google()
maven("https://jitpack.io") maven("https://jitpack.io")
maven("https://github.com/Suwayomi/Tachidesk-Server/raw/android-jar/")
} }
} }
@@ -29,6 +31,7 @@ val projects = listOf(
configure(projects) { configure(projects) {
apply(plugin = "org.jetbrains.kotlin.jvm") apply(plugin = "org.jetbrains.kotlin.jvm")
apply(plugin = "org.jetbrains.kotlin.plugin.serialization") apply(plugin = "org.jetbrains.kotlin.plugin.serialization")
apply(plugin = "org.jmailen.kotlinter")
java { java {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_1_8
@@ -37,6 +40,7 @@ configure(projects) {
tasks { tasks {
withType<KotlinCompile> { withType<KotlinCompile> {
dependsOn(formatKotlin)
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8.toString() jvmTarget = JavaVersion.VERSION_1_8.toString()
freeCompilerArgs = listOf( freeCompilerArgs = listOf(
+2 -2
View File
@@ -12,9 +12,9 @@ const val kotlinVersion = "1.5.30"
const val MainClass = "suwayomi.tachidesk.MainKt" const val MainClass = "suwayomi.tachidesk.MainKt"
// should be bumped with each stable release // should be bumped with each stable release
val tachideskVersion = System.getenv("ProductVersion") ?: "v0.5.2" val tachideskVersion = System.getenv("ProductVersion") ?: "v0.6.0"
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r807" val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r893"
// counts commits on the master branch // counts commits on the master branch
val tachideskRevision = runCatching { val tachideskRevision = runCatching {
+2 -2
View File
@@ -1,4 +1,4 @@
#!/bin/bash #/bin/bash
# Copyright (C) Contributors to the Suwayomi project # Copyright (C) Contributors to the Suwayomi project
# #
@@ -24,7 +24,7 @@ elif [ $1 = "macOS-arm64" ]; then
jre="zulu8.56.0.23-ca-jre8.0.302-macosx_aarch64.tar.gz" jre="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_release="zulu8.56.0.23-ca-jre8.0.302-macosx_aarch64"
jre_url="https://cdn.azul.com/zulu/bin/$jre" jre_url="https://cdn.azul.com/zulu/bin/$jre"
jre_dir="$jre_release" jre_dir="$jre_release/zulu-8.jre"
electron="electron-$electron_version-darwin-arm64.zip" electron="electron-$electron_version-darwin-arm64.zip"
else else
echo "Unsupported arch value: $1" echo "Unsupported arch value: $1"
+2 -2
View File
@@ -12,13 +12,13 @@ if [ $1 = "win32" ]; then
jre="OpenJDK8U-jre_x86-32_windows_hotspot_8u292b10.zip" jre="OpenJDK8U-jre_x86-32_windows_hotspot_8u292b10.zip"
jre_release="jdk8u292-b10" jre_release="jdk8u292-b10"
jre_url="https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/$jre_release/$jre" jre_url="https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/$jre_release/$jre"
arch="win32" arch="windows-x86"
electron="electron-$electron_version-win32-ia32.zip" electron="electron-$electron_version-win32-ia32.zip"
else else
jre="OpenJDK8U-jre_x64_windows_hotspot_8u302b08.zip" jre="OpenJDK8U-jre_x64_windows_hotspot_8u302b08.zip"
jre_release="jdk8u302-b08" jre_release="jdk8u302-b08"
jre_url="https://github.com/adoptium/temurin8-binaries/releases/download/$jre_release/$jre" jre_url="https://github.com/adoptium/temurin8-binaries/releases/download/$jre_release/$jre"
arch="win64" arch="windows-x64"
electron="electron-$electron_version-win32-x64.zip" electron="electron-$electron_version-win32-x64.zip"
fi fi
+25 -19
View File
@@ -1,11 +1,10 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import de.undercouch.gradle.tasks.download.Download import de.undercouch.gradle.tasks.download.Download
import java.time.Instant import java.time.Instant
plugins { plugins {
application application
id("com.github.johnrengelman.shadow") version "7.0.0" id("com.github.johnrengelman.shadow") version "7.0.0"
id("com.github.gmazzo.buildconfig") version "3.0.3" id("com.github.gmazzo.buildconfig")
} }
dependencies { dependencies {
@@ -17,7 +16,8 @@ dependencies {
implementation("com.squareup.okio:okio:2.10.0") implementation("com.squareup.okio:okio:2.10.0")
// Javalin api // Javalin api
implementation("io.javalin:javalin:4.0.0") implementation("io.javalin:javalin:4.1.1")
implementation("io.javalin:javalin-openapi:4.1.1")
// jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency` // jackson version locked by javalin, ref: `io.javalin.core.util.OptionalDependency`
val jacksonVersion = "2.12.4" val jacksonVersion = "2.12.4"
implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
@@ -33,7 +33,7 @@ dependencies {
implementation("com.h2database:h2:1.4.200") implementation("com.h2database:h2:1.4.200")
// Exposed Migrations // Exposed Migrations
implementation("com.github.Suwayomi:exposed-migrations:3.1.2") implementation("com.github.Suwayomi:exposed-migrations:3.1.4")
// tray icon // tray icon
implementation("com.dorkbox:SystemTray:4.1") implementation("com.dorkbox:SystemTray:4.1")
@@ -70,16 +70,13 @@ dependencies {
// uncomment to test extensions directly // uncomment to test extensions directly
// implementation(fileTree("lib/")) // implementation(fileTree("lib/"))
implementation(kotlin("script-runtime"))
testImplementation("io.mockk:mockk:1.9.3")
} }
application { application {
mainClass.set(MainClass) mainClass.set(MainClass)
// uncomment for testing electron
// applicationDefaultJvmArgs = listOf(
// "-Dsuwayomi.tachidesk.config.server.webUIInterface=electron",
// "-Dsuwayomi.tachidesk.config.server.electronPath=/usr/bin/electron"
// )
} }
sourceSets { sourceSets {
@@ -127,18 +124,15 @@ tasks {
archiveBaseName.set(rootProject.name) archiveBaseName.set(rootProject.name)
archiveVersion.set(tachideskVersion) archiveVersion.set(tachideskVersion)
archiveClassifier.set(tachideskRevision) archiveClassifier.set(tachideskRevision)
}
test {
useJUnit()
}
withType<ShadowJar> {
destinationDirectory.set(File("$rootDir/server/build")) destinationDirectory.set(File("$rootDir/server/build"))
} }
named("run") { test {
dependsOn(":formatKotlin", ":lintKotlin") useJUnitPlatform()
testLogging {
showStandardStreams = true
events("passed", "skipped", "failed")
}
} }
named<Copy>("processResources") { named<Copy>("processResources") {
@@ -171,4 +165,16 @@ tasks {
overwrite(shouldOverwrite()) overwrite(shouldOverwrite())
} }
register("runElectron") {
group = "application"
finalizedBy(run)
doFirst {
application.applicationDefaultJvmArgs = listOf(
"-Dsuwayomi.tachidesk.config.server.webUIInterface=electron",
// Change this to the installed electron application
"-Dsuwayomi.tachidesk.config.server.electronPath=/usr/bin/electron"
)
}
}
} }
@@ -0,0 +1,6 @@
package eu.kanade.tachiyomi;
public class BuildConfig {
public static final int VERSION_CODE = -1;
public static final String VERSION_NAME = "stub";
}
@@ -1,46 +0,0 @@
package eu.kanade.tachiyomi.animesource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage
import rx.Observable
interface AnimeCatalogueSource : AnimeSource {
/**
* An ISO 639-1 compliant language code (two letters in lower case).
*/
override val lang: String
/**
* Whether the source has support for latest updates.
*/
val supportsLatest: Boolean
/**
* Returns an observable containing a page with a list of anime.
*
* @param page the page number to retrieve.
*/
fun fetchPopularAnime(page: Int): Observable<AnimesPage>
/**
* Returns an observable containing a page with a list of anime.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage>
/**
* Returns an observable containing a page with a list of latest anime updates.
*
* @param page the page number to retrieve.
*/
fun fetchLatestUpdates(page: Int): Observable<AnimesPage>
/**
* Returns the list of filters for the source.
*/
fun getFilterList(): AnimeFilterList
}
@@ -1,81 +0,0 @@
package eu.kanade.tachiyomi.animesource
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Video
import rx.Observable
/**
* A basic interface for creating a source. It could be an online source, a local source, etc...
*/
interface AnimeSource {
/**
* Id for the source. Must be unique.
*/
val id: Long
/**
* Name of the source.
*/
val name: String
val lang: String
get() = ""
/**
* Returns an observable with the updated details for a anime.
*
* @param anime the anime to update.
*/
// @Deprecated("Use getAnimeDetails instead")
fun fetchAnimeDetails(anime: SAnime): Observable<SAnime>
/**
* Returns an observable with all the available episodes for an anime.
*
* @param anime the anime to update.
*/
// @Deprecated("Use getEpisodeList instead")
fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>>
/**
* Returns an observable with a list of video for the episode of an anime.
*
* @param episode the episode to get the link for.
*/
// @Deprecated("Use getEpisodeList instead")
fun fetchVideoList(episode: SEpisode): Observable<List<Video>>
// /**
// * [1.x API] Get the updated details for a anime.
// */
// @Suppress("DEPRECATION")
// override suspend fun getAnimeDetails(anime: AnimeInfo): AnimeInfo {
// val sAnime = anime.toSAnime()
// val networkAnime = fetchAnimeDetails(sAnime).awaitSingle()
// sAnime.copyFrom(networkAnime)
// return sAnime.toAnimeInfo()
// }
// /**
// * [1.x API] Get all the available episodes for a anime.
// */
// @Suppress("DEPRECATION")
// override suspend fun getEpisodeList(anime: AnimeInfo): List<EpisodeInfo> {
// return fetchEpisodeList(anime.toSAnime()).awaitSingle()
// .map { it.toEpisodeInfo() }
// }
// /**
// * [1.x API] Get a link for the episode of an anime.
// */
// @Suppress("DEPRECATION")
// override suspend fun getEpisodeLink(episode: EpisodeInfo): String {
// return fetchEpisodeLink(episode.toSEpisode()).awaitSingle()
// }
}
// fun AnimeSource.icon(): Drawable? = Injekt.get<AnimeExtensionManager>().getAppIconForSource(this)
fun AnimeSource.getPreferenceKey(): String = "source_$id"
@@ -1,12 +0,0 @@
package eu.kanade.tachiyomi.animesource
/**
* A factory for creating sources at runtime.
*/
interface AnimeSourceFactory {
/**
* Create a new copy of the sources
* @return The created sources
*/
fun createSources(): List<AnimeSource>
}
@@ -1,8 +0,0 @@
package eu.kanade.tachiyomi.animesource
import androidx.preference.PreferenceScreen
interface ConfigurableAnimeSource : AnimeSource {
fun setupPreferenceScreen(screen: PreferenceScreen)
}
@@ -1,40 +0,0 @@
package eu.kanade.tachiyomi.animesource.model
sealed class AnimeFilter<T>(val name: String, var state: T) {
open class Header(name: String) : AnimeFilter<Any>(name, 0)
open class Separator(name: String = "") : AnimeFilter<Any>(name, 0)
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : AnimeFilter<Int>(name, state)
abstract class Text(name: String, state: String = "") : AnimeFilter<String>(name, state)
abstract class CheckBox(name: String, state: Boolean = false) : AnimeFilter<Boolean>(name, state)
abstract class TriState(name: String, state: Int = STATE_IGNORE) : AnimeFilter<Int>(name, state) {
fun isIgnored() = state == STATE_IGNORE
fun isIncluded() = state == STATE_INCLUDE
fun isExcluded() = state == STATE_EXCLUDE
companion object {
const val STATE_IGNORE = 0
const val STATE_INCLUDE = 1
const val STATE_EXCLUDE = 2
}
}
abstract class Group<V>(name: String, state: List<V>) : AnimeFilter<List<V>>(name, state)
abstract class Sort(name: String, val values: Array<String>, state: Selection? = null) :
AnimeFilter<Sort.Selection?>(name, state) {
data class Selection(val index: Int, val ascending: Boolean)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is AnimeFilter<*>) return false
return name == other.name && state == other.state
}
override fun hashCode(): Int {
var result = name.hashCode()
result = 31 * result + (state?.hashCode() ?: 0)
return result
}
}
@@ -1,6 +0,0 @@
package eu.kanade.tachiyomi.animesource.model
data class AnimeFilterList(val list: List<AnimeFilter<*>>) : List<AnimeFilter<*>> by list {
constructor(vararg fs: AnimeFilter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
}
@@ -1,3 +0,0 @@
package eu.kanade.tachiyomi.animesource.model
data class AnimesPage(val animes: List<SAnime>, val hasNextPage: Boolean)
@@ -1,93 +0,0 @@
package eu.kanade.tachiyomi.animesource.model
// import tachiyomi.animesource.model.AnimeInfo
import java.io.Serializable
interface SAnime : Serializable {
var url: String
var title: String
var artist: String?
var author: String?
var description: String?
var genre: String?
var status: Int
var thumbnail_url: String?
var initialized: Boolean
fun copyFrom(other: SAnime) {
title = other.title
if (other.author != null) {
author = other.author
}
if (other.artist != null) {
artist = other.artist
}
if (other.description != null) {
description = other.description
}
if (other.genre != null) {
genre = other.genre
}
if (other.thumbnail_url != null) {
thumbnail_url = other.thumbnail_url
}
status = other.status
if (!initialized) {
initialized = other.initialized
}
}
companion object {
const val UNKNOWN = 0
const val ONGOING = 1
const val COMPLETED = 2
const val LICENSED = 3
fun create(): SAnime {
return SAnimeImpl()
}
}
}
// fun SAnime.toAnimeInfo(): AnimeInfo {
// return AnimeInfo(
// key = this.url,
// title = this.title,
// artist = this.artist ?: "",
// author = this.author ?: "",
// description = this.description ?: "",
// genres = this.genre?.split(", ") ?: emptyList(),
// status = this.status,
// cover = this.thumbnail_url ?: ""
// )
// }
// fun AnimeInfo.toSAnime(): SAnime {
// val animeInfo = this
// return SAnime.create().apply {
// url = animeInfo.key
// title = animeInfo.title
// artist = animeInfo.artist
// author = animeInfo.author
// description = animeInfo.description
// genre = animeInfo.genres.joinToString(", ")
// status = animeInfo.status
// thumbnail_url = animeInfo.cover
// }
// }
@@ -1,22 +0,0 @@
package eu.kanade.tachiyomi.animesource.model
class SAnimeImpl : SAnime {
override lateinit var url: String
override lateinit var title: String
override var artist: String? = null
override var author: String? = null
override var description: String? = null
override var genre: String? = null
override var status: Int = 0
override var thumbnail_url: String? = null
override var initialized: Boolean = false
}
@@ -1,52 +0,0 @@
package eu.kanade.tachiyomi.animesource.model
// import tachiyomi.animesource.model.EpisodeInfo
import java.io.Serializable
interface SEpisode : Serializable {
var url: String
var name: String
var date_upload: Long
var episode_number: Float
var scanlator: String?
fun copyFrom(other: SEpisode) {
name = other.name
url = other.url
date_upload = other.date_upload
episode_number = other.episode_number
scanlator = other.scanlator
}
companion object {
fun create(): SEpisode {
return SEpisodeImpl()
}
}
}
// fun SEpisode.toEpisodeInfo(): EpisodeInfo {
// return EpisodeInfo(
// dateUpload = this.date_upload,
// key = this.url,
// name = this.name,
// number = this.episode_number,
// scanlator = this.scanlator ?: ""
// )
// }
//
// fun EpisodeInfo.toSEpisode(): SEpisode {
// val episode = this
// return SEpisode.create().apply {
// url = episode.key
// name = episode.name
// date_upload = episode.dateUpload
// episode_number = episode.number
// scanlator = episode.scanlator
// }
// }
@@ -1,14 +0,0 @@
package eu.kanade.tachiyomi.animesource.model
class SEpisodeImpl : SEpisode {
override lateinit var url: String
override lateinit var name: String
override var date_upload: Long = 0
override var episode_number: Float = -1f
override var scanlator: String? = null
}
@@ -1,73 +0,0 @@
package eu.kanade.tachiyomi.animesource.model
import android.net.Uri
import eu.kanade.tachiyomi.network.ProgressListener
import rx.subjects.Subject
// import tachiyomi.animesource.model.VideoUrl
open class Video(
val url: String = "",
val quality: String = "",
var videoUrl: String? = null,
@Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions
) : ProgressListener {
@Transient
@Volatile
var status: Int = 0
set(value) {
field = value
statusSubject?.onNext(value)
statusCallback?.invoke(this)
}
@Transient
@Volatile
var progress: Int = 0
set(value) {
field = value
statusCallback?.invoke(this)
}
@Transient
private var statusSubject: Subject<Int, Int>? = null
@Transient
private var statusCallback: ((Video) -> Unit)? = null
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
progress = if (contentLength > 0) {
(100 * bytesRead / contentLength).toInt()
} else {
-1
}
}
fun setStatusSubject(subject: Subject<Int, Int>?) {
this.statusSubject = subject
}
fun setStatusCallback(f: ((Video) -> Unit)?) {
statusCallback = f
}
companion object {
const val QUEUE = 0
const val LOAD_VIDEO = 1
const val DOWNLOAD_IMAGE = 2
const val READY = 3
const val ERROR = 4
}
}
// fun Video.toVideoUrl(): VideoUrl {
// return VideoUrl(
// url = this.videoUrl ?: this.url
// )
// }
//
// fun VideoUrl.toVideo(index: Int): Video {
// return Video(
// videoUrl = this.url
// )
// }
@@ -1,376 +0,0 @@
package eu.kanade.tachiyomi.animesource.online
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.newCallWithProgress
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.net.URI
import java.net.URISyntaxException
import java.security.MessageDigest
/**
* A simple implementation for sources from a website.
*/
abstract class AnimeHttpSource : AnimeCatalogueSource {
/**
* Network service.
*/
protected val network: NetworkHelper by injectLazy()
// /**
// * Preferences that a source may need.
// */
// val preferences: SharedPreferences by lazy {
// Injekt.get<Application>().getSharedPreferences(source.getPreferenceKey(), Context.MODE_PRIVATE)
// }
/**
* Base url of the website without the trailing slash, like: http://mysite.com
*/
abstract val baseUrl: String
/**
* Version id used to generate the source id. If the site completely changes and urls are
* incompatible, you may increase this value and it'll be considered as a new source.
*/
open val versionId = 1
/**
* Id of the source. By default it uses a generated id using the first 16 characters (64 bits)
* of the MD5 of the string: sourcename/language/versionId
* Note the generated id sets the sign bit to 0.
*/
override val id by lazy {
val key = "${name.lowercase()}/$lang/$versionId"
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
}
/**
* Headers used for requests.
*/
val headers: Headers by lazy { headersBuilder().build() }
/**
* Default network client for doing requests.
*/
open val client: OkHttpClient
get() = network.client
/**
* Headers builder for requests. Implementations can override this method for custom headers.
*/
protected open fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", DEFAULT_USER_AGENT)
}
/**
* Visible name of the source.
*/
override fun toString() = "$name (${lang.uppercase()})"
/**
* Returns an observable containing a page with a list of anime. Normally it's not needed to
* override this method.
*
* @param page the page number to retrieve.
*/
override fun fetchPopularAnime(page: Int): Observable<AnimesPage> {
return client.newCall(popularAnimeRequest(page))
.asObservableSuccess()
.map { response ->
popularAnimeParse(response)
}
}
/**
* Returns the request for the popular anime given the page.
*
* @param page the page number to retrieve.
*/
protected abstract fun popularAnimeRequest(page: Int): Request
/**
* Parses the response from the site and returns a [AnimesPage] object.
*
* @param response the response from the site.
*/
protected abstract fun popularAnimeParse(response: Response): AnimesPage
/**
* Returns an observable containing a page with a list of anime. Normally it's not needed to
* override this method.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage> {
return client.newCall(searchAnimeRequest(page, query, filters))
.asObservableSuccess()
.map { response ->
searchAnimeParse(response)
}
}
/**
* Returns the request for the search anime given the page.
*
* @param page the page number to retrieve.
* @param query the search query.
* @param filters the list of filters to apply.
*/
protected abstract fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request
/**
* Parses the response from the site and returns a [AnimesPage] object.
*
* @param response the response from the site.
*/
protected abstract fun searchAnimeParse(response: Response): AnimesPage
/**
* Returns an observable containing a page with a list of latest anime updates.
*
* @param page the page number to retrieve.
*/
override fun fetchLatestUpdates(page: Int): Observable<AnimesPage> {
return client.newCall(latestUpdatesRequest(page))
.asObservableSuccess()
.map { response ->
latestUpdatesParse(response)
}
}
/**
* Returns the request for latest anime given the page.
*
* @param page the page number to retrieve.
*/
protected abstract fun latestUpdatesRequest(page: Int): Request
/**
* Parses the response from the site and returns a [AnimesPage] object.
*
* @param response the response from the site.
*/
protected abstract fun latestUpdatesParse(response: Response): AnimesPage
/**
* Returns an observable with the updated details for a anime. Normally it's not needed to
* override this method.
*
* @param anime the anime to be updated.
*/
override fun fetchAnimeDetails(anime: SAnime): Observable<SAnime> {
return client.newCall(animeDetailsRequest(anime))
.asObservableSuccess()
.map { response ->
animeDetailsParse(response).apply { initialized = true }
}
}
/**
* Returns the request for the details of a anime. Override only if it's needed to change the
* url, send different headers or request method like POST.
*
* @param anime the anime to be updated.
*/
open fun animeDetailsRequest(anime: SAnime): Request {
return GET(baseUrl + anime.url, headers)
}
/**
* Parses the response from the site and returns the details of a anime.
*
* @param response the response from the site.
*/
protected abstract fun animeDetailsParse(response: Response): SAnime
/**
* Returns an observable with the updated episode list for a anime. Normally it's not needed to
* override this method. If a anime is licensed an empty episode list observable is returned
*
* @param anime the anime to look for episodes.
*/
override fun fetchEpisodeList(anime: SAnime): Observable<List<SEpisode>> {
return if (anime.status != SAnime.LICENSED) {
client.newCall(episodeListRequest(anime))
.asObservableSuccess()
.map { response ->
episodeListParse(response)
}
} else {
Observable.error(Exception("Licensed - No episodes to show"))
}
}
/**
* Returns the request for updating the episode list. Override only if it's needed to override
* the url, send different headers or request method like POST.
*
* @param anime the anime to look for episodes.
*/
protected open fun episodeListRequest(anime: SAnime): Request {
return GET(baseUrl + anime.url, headers)
}
/**
* Parses the response from the site and returns a list of episodes.
*
* @param response the response from the site.
*/
protected abstract fun episodeListParse(response: Response): List<SEpisode>
/**
* Returns an observable with the page list for a chapter.
*
* @param chapter the chapter whose page list has to be fetched.
*/
override fun fetchVideoList(episode: SEpisode): Observable<List<Video>> {
return client.newCall(videoListRequest(episode))
.asObservableSuccess()
.map { response ->
videoListParse(response)
}
}
/**
* Returns the request for getting the episode link. Override only if it's needed to override
* the url, send different headers or request method like POST.
*
* @param episode the episode to look for links.
*/
protected open fun videoListRequest(episode: SEpisode): Request {
return GET(baseUrl + episode.url, headers)
}
/**
* Parses the response from the site and returns a list of pages.
*
* @param response the response from the site.
*/
protected abstract fun videoListParse(response: Response): List<Video>
/**
* Returns an observable with the page containing the source url of the image. If there's any
* error, it will return null instead of throwing an exception.
*
* @param page the page whose source image has to be fetched.
*/
open fun fetchVideoUrl(video: Video): Observable<String> {
return client.newCall(videoUrlRequest(video))
.asObservableSuccess()
.map { videoUrlParse(it) }
}
/**
* Returns the request for getting the url to the source image. Override only if it's needed to
* override the url, send different headers or request method like POST.
*
* @param page the chapter whose page list has to be fetched
*/
protected open fun videoUrlRequest(video: Video): Request {
return GET(video.url, headers)
}
/**
* Parses the response from the site and returns the absolute url to the source image.
*
* @param response the response from the site.
*/
protected abstract fun videoUrlParse(response: Response): String
/**
* Returns an observable with the response of the source image.
*
* @param page the page whose source image has to be downloaded.
*/
fun fetchVideo(video: Video): Observable<Response> {
return client.newCallWithProgress(videoRequest(video), video)
.asObservableSuccess()
}
/**
* Returns the request for getting the source image. Override only if it's needed to override
* the url, send different headers or request method like POST.
*
* @param video the video whose link has to be fetched
*/
protected open fun videoRequest(video: Video): Request {
return GET(video.videoUrl!!, headers)
}
/**
* Assigns the url of the episode without the scheme and domain. It saves some redundancy from
* database and the urls could still work after a domain change.
*
* @param url the full url to the episode.
*/
fun SEpisode.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url)
}
/**
* Assigns the url of the anime without the scheme and domain. It saves some redundancy from
* database and the urls could still work after a domain change.
*
* @param url the full url to the anime.
*/
fun SAnime.setUrlWithoutDomain(url: String) {
this.url = getUrlWithoutDomain(url)
}
/**
* Returns the url of the given string without the scheme and domain.
*
* @param orig the full url.
*/
private fun getUrlWithoutDomain(orig: String): String {
return try {
val uri = URI(orig)
var out = uri.path
if (uri.query != null) {
out += "?" + uri.query
}
if (uri.fragment != null) {
out += "#" + uri.fragment
}
out
} catch (e: URISyntaxException) {
orig
}
}
/**
* Called before inserting a new episode into database. Use it if you need to override episode
* fields, like the title or the episode number. Do not change anything to [anime].
*
* @param episode the episode to be added.
* @param anime the anime of the episode.
*/
open fun prepareNewEpisode(episode: SEpisode, anime: SAnime) {
}
/**
* Returns the list of filters for the source.
*/
override fun getFilterList() = AnimeFilterList()
companion object {
const val DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36 Edg/88.0.705.63"
}
}
@@ -1,25 +0,0 @@
package eu.kanade.tachiyomi.animesource.online
import eu.kanade.tachiyomi.animesource.model.Video
import rx.Observable
fun AnimeHttpSource.getVideoUrl(video: Video): Observable<Video> {
video.status = Video.LOAD_VIDEO
return fetchVideoUrl(video)
.doOnError { video.status = Video.ERROR }
.onErrorReturn { null }
.doOnNext { video.videoUrl = it }
.map { video }
}
fun AnimeHttpSource.fetchUrlFromVideo(video: Video): Observable<Video> {
return Observable.just(video)
.filter { !it.videoUrl.isNullOrEmpty() }
.mergeWith(fetchRemainingVideoUrlsFromVideoList(video))
}
fun AnimeHttpSource.fetchRemainingVideoUrlsFromVideoList(video: Video): Observable<Video> {
return Observable.just(video)
.filter { it.videoUrl.isNullOrEmpty() }
.concatMap { getVideoUrl(it) }
}
@@ -1,206 +0,0 @@
package eu.kanade.tachiyomi.animesource.online
import eu.kanade.tachiyomi.animesource.model.AnimesPage
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.util.asJsoup
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
/**
* A simple implementation for sources from a website using Jsoup, an HTML parser.
*/
abstract class ParsedAnimeHttpSource : AnimeHttpSource() {
/**
* Parses the response from the site and returns a [AnimesPage] object.
*
* @param response the response from the site.
*/
override fun popularAnimeParse(response: Response): AnimesPage {
val document = response.asJsoup()
val animes = document.select(popularAnimeSelector()).map { element ->
popularAnimeFromElement(element)
}
val hasNextPage = popularAnimeNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return AnimesPage(animes, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each anime.
*/
protected abstract fun popularAnimeSelector(): String
/**
* Returns a anime from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [popularAnimeSelector].
*/
protected abstract fun popularAnimeFromElement(element: Element): SAnime
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
protected abstract fun popularAnimeNextPageSelector(): String?
/**
* Parses the response from the site and returns a [AnimesPage] object.
*
* @param response the response from the site.
*/
override fun searchAnimeParse(response: Response): AnimesPage {
val document = response.asJsoup()
val animes = document.select(searchAnimeSelector()).map { element ->
searchAnimeFromElement(element)
}
val hasNextPage = searchAnimeNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return AnimesPage(animes, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each anime.
*/
protected abstract fun searchAnimeSelector(): String
/**
* Returns a anime from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [searchAnimeSelector].
*/
protected abstract fun searchAnimeFromElement(element: Element): SAnime
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
protected abstract fun searchAnimeNextPageSelector(): String?
/**
* Parses the response from the site and returns a [AnimesPage] object.
*
* @param response the response from the site.
*/
override fun latestUpdatesParse(response: Response): AnimesPage {
val document = response.asJsoup()
val animes = document.select(latestUpdatesSelector()).map { element ->
latestUpdatesFromElement(element)
}
val hasNextPage = latestUpdatesNextPageSelector()?.let { selector ->
document.select(selector).first()
} != null
return AnimesPage(animes, hasNextPage)
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each anime.
*/
protected abstract fun latestUpdatesSelector(): String
/**
* Returns a anime from the given [element]. Most sites only show the title and the url, it's
* totally fine to fill only those two values.
*
* @param element an element obtained from [latestUpdatesSelector].
*/
protected abstract fun latestUpdatesFromElement(element: Element): SAnime
/**
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
* there's no next page.
*/
protected abstract fun latestUpdatesNextPageSelector(): String?
/**
* Parses the response from the site and returns the details of a anime.
*
* @param response the response from the site.
*/
override fun animeDetailsParse(response: Response): SAnime {
return animeDetailsParse(response.asJsoup())
}
/**
* Returns the details of the anime from the given [document].
*
* @param document the parsed document.
*/
protected abstract fun animeDetailsParse(document: Document): SAnime
/**
* Parses the response from the site and returns a list of episodes.
*
* @param response the response from the site.
*/
override fun episodeListParse(response: Response): List<SEpisode> {
val document = response.asJsoup()
return document.select(episodeListSelector()).map { episodeFromElement(it) }
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each episode.
*/
protected abstract fun episodeListSelector(): String
/**
* Returns a episode from the given element.
*
* @param element an element obtained from [episodeListSelector].
*/
protected abstract fun episodeFromElement(element: Element): SEpisode
/**
* Parses the response from the site and returns the page list.
*
* @param response the response from the site.
*/
override fun videoListParse(response: Response): List<Video> {
val document = response.asJsoup()
return document.select(videoListSelector()).map { videoFromElement(it) }
}
/**
* Returns the Jsoup selector that returns a list of [Element] corresponding to each video.
*/
protected abstract fun videoListSelector(): String
/**
* Returns a video from the given element.
*
* @param element an element obtained from [videoListSelector].
*/
protected abstract fun videoFromElement(element: Element): Video
/**
* Parse the response from the site and returns the absolute url to the source video.
*
* @param response the response from the site.
*/
override fun videoUrlParse(response: Response): String {
return videoUrlParse(response.asJsoup())
}
/**
* Returns the absolute url to the source image from the document.
*
* @param document the parsed document.
*/
protected abstract fun videoUrlParse(document: Document): String
}
@@ -1,5 +0,0 @@
package eu.kanade.tachiyomi.annoations
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
annotation class Nsfw
@@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.source.local package eu.kanade.tachiyomi.source.local
import com.github.junrar.Archive import com.github.junrar.Archive
import eu.kanade.tachiyomi.source.local.FileSystemInterceptor.fakeUrlFrom import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Directory 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.Epub
import eu.kanade.tachiyomi.source.local.LocalSource.Format.Rar import eu.kanade.tachiyomi.source.local.LocalSource.Format.Rar
@@ -15,7 +15,6 @@ import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga 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.chapter.ChapterRecognition
import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder import eu.kanade.tachiyomi.util.lang.compareToCaseInsensitiveNaturalOrder
import eu.kanade.tachiyomi.util.storage.EpubFile import eu.kanade.tachiyomi.util.storage.EpubFile
@@ -27,15 +26,6 @@ import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import mu.KotlinLogging 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.insert
import org.jetbrains.exposed.sql.insertAndGetId import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
@@ -44,6 +34,7 @@ import org.kodein.di.DI
import org.kodein.di.conf.global import org.kodein.di.conf.global
import org.kodein.di.instance import org.kodein.di.instance
import rx.Observable import rx.Observable
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.registerCatalogueSource
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import suwayomi.tachidesk.manga.model.table.ExtensionTable import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.manga.model.table.SourceTable
@@ -51,14 +42,12 @@ import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileNotFoundException
import java.io.InputStream import java.io.InputStream
import java.net.URLDecoder
import java.util.Locale import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile import java.util.zip.ZipFile
class LocalSource : HttpSource() { class LocalSource : CatalogueSource {
companion object { companion object {
const val ID = 0L const val ID = 0L
const val LANG = "localsourcelang" const val LANG = "localsourcelang"
@@ -101,7 +90,7 @@ class LocalSource : HttpSource() {
} }
} }
fun addDbRecords() { fun register() {
transaction { transaction {
val sourceRecord = SourceTable.select { SourceTable.id eq ID }.firstOrNull() val sourceRecord = SourceTable.select { SourceTable.id eq ID }.firstOrNull()
@@ -127,19 +116,16 @@ class LocalSource : HttpSource() {
} }
} }
} }
registerCatalogueSource(ID to LocalSource())
} }
} }
override val id = ID override val id = ID
override val name = NAME override val name = NAME
override val lang = LANG override val lang = LANG
override val baseUrl: String = ""
override val supportsLatest = true override val supportsLatest = true
override val client: OkHttpClient = super.client.newBuilder()
.addInterceptor(FileSystemInterceptor)
.build()
private val json: Json by injectLazy() private val json: Json by injectLazy()
override fun toString() = name override fun toString() = name
@@ -181,7 +167,7 @@ class LocalSource : HttpSource() {
// Try to find the cover // Try to find the cover
val cover = getCoverFile(File("${applicationDirs.localMangaRoot}/$url")) val cover = getCoverFile(File("${applicationDirs.localMangaRoot}/$url"))
if (cover != null && cover.exists()) { if (cover != null && cover.exists()) {
thumbnail_url = fakeUrlFrom(cover.absolutePath) thumbnail_url = cover.absolutePath
} }
val chapters = fetchChapterList(this).toBlocking().first() val chapters = fetchChapterList(this).toBlocking().first()
@@ -197,8 +183,7 @@ class LocalSource : HttpSource() {
// Copy the cover from the first chapter found. // Copy the cover from the first chapter found.
if (thumbnail_url == null) { if (thumbnail_url == null) {
try { try {
val dest = updateCover(chapter, this) thumbnail_url = updateCover(chapter, this)?.absolutePath
thumbnail_url = dest?.absolutePath?.let { fakeUrlFrom(it) }
} catch (e: Exception) { } catch (e: Exception) {
logger.error { e } logger.error { e }
} }
@@ -311,7 +296,7 @@ class LocalSource : HttpSource() {
chapterFile.listFiles().orEmpty().sortedBy { it.name }.mapIndexed { index, page -> chapterFile.listFiles().orEmpty().sortedBy { it.name }.mapIndexed { index, page ->
Page( Page(
index, index,
imageUrl = fakeUrlFrom(applicationDirs.localMangaRoot + "/" + chapter.url + "/" + page.name) imageUrl = applicationDirs.localMangaRoot + "/" + chapter.url + "/" + page.name
) )
} }
) )
@@ -412,67 +397,4 @@ class LocalSource : HttpSource() {
data class Rar(val file: File) : Format() data class Rar(val file: File) : Format()
data class Epub(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()
}
}
} }
@@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.source.model package eu.kanade.tachiyomi.source.model
sealed class Filter<T>(val name: String, var state: T) { // The class is originally sealed, Tachidesk adds new subclasses for serialization
// sealed class Filter<T>(val name: String, var state: T) {
open class Filter<T>(val name: String, var state: T) {
open class Header(name: String) : Filter<Any>(name, 0) open class Header(name: String) : Filter<Any>(name, 0)
open class Separator(name: String = "") : Filter<Any>(name, 0) open class Separator(name: String = "") : Filter<Any>(name, 0)
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state) abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state)
@@ -1,380 +0,0 @@
package suwayomi.tachidesk.anime
/*
* 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 io.javalin.Javalin
import suwayomi.tachidesk.anime.impl.Anime.getAnime
import suwayomi.tachidesk.anime.impl.Anime.getAnimeThumbnail
import suwayomi.tachidesk.anime.impl.AnimeList.getAnimeList
import suwayomi.tachidesk.anime.impl.Episode.getEpisode
import suwayomi.tachidesk.anime.impl.Episode.getEpisodeList
import suwayomi.tachidesk.anime.impl.Episode.modifyEpisode
import suwayomi.tachidesk.anime.impl.Search.sourceSearch
import suwayomi.tachidesk.anime.impl.Source.getAnimeSource
import suwayomi.tachidesk.anime.impl.Source.getSourceList
import suwayomi.tachidesk.anime.impl.extension.Extension.getExtensionIcon
import suwayomi.tachidesk.anime.impl.extension.Extension.installExtension
import suwayomi.tachidesk.anime.impl.extension.Extension.uninstallExtension
import suwayomi.tachidesk.anime.impl.extension.Extension.updateExtension
import suwayomi.tachidesk.anime.impl.extension.ExtensionsList.getExtensionList
import suwayomi.tachidesk.server.JavalinSetup.future
object AnimeAPI {
fun defineEndpoints(app: Javalin) {
// list all extensions
app.get("/api/v1/anime/extension/list") { ctx ->
ctx.future(
future {
getExtensionList()
}
)
}
// install extension identified with "pkgName"
app.get("/api/v1/anime/extension/install/{pkgName}") { ctx ->
val pkgName = ctx.pathParam("pkgName")
ctx.future(
future {
installExtension(pkgName)
}
)
}
// update extension identified with "pkgName"
app.get("/api/v1/anime/extension/update/{pkgName}") { ctx ->
val pkgName = ctx.pathParam("pkgName")
ctx.future(
future {
updateExtension(pkgName)
}
)
}
// uninstall extension identified with "pkgName"
app.get("/api/v1/anime/extension/uninstall/{pkgName}") { ctx ->
val pkgName = ctx.pathParam("pkgName")
uninstallExtension(pkgName)
ctx.status(200)
}
// icon for extension named `apkName`
app.get("/api/v1/anime/extension/icon/{apkName}") { ctx -> // TODO: move to pkgName
val apkName = ctx.pathParam("apkName")
ctx.future(
future { getExtensionIcon(apkName) }
.thenApply {
ctx.header("content-type", it.second)
it.first
}
)
}
// list of sources
app.get("/api/v1/anime/source/list") { ctx ->
ctx.json(getSourceList())
}
// fetch source with id `sourceId`
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 ->
val sourceId = ctx.pathParam("sourceId").toLong()
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.future(
future {
getAnimeList(sourceId, pageNum, popular = true)
}
)
}
// latest animes from source with id `sourceId`
app.get("/api/v1/anime/source/{sourceId}/latest/{pageNum}") { ctx ->
val sourceId = ctx.pathParam("sourceId").toLong()
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.future(
future {
getAnimeList(sourceId, pageNum, popular = false)
}
)
}
// get anime info
app.get("/api/v1/anime/anime/{animeId}/") { ctx ->
val animeId = ctx.pathParam("animeId").toInt()
val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean() ?: false
ctx.future(
future {
getAnime(animeId, onlineFetch)
}
)
}
// anime thumbnail
app.get("api/v1/anime/anime/{animeId}/thumbnail") { ctx ->
val animeId = ctx.pathParam("animeId").toInt()
ctx.future(
future { getAnimeThumbnail(animeId) }
.thenApply {
ctx.header("content-type", it.second)
it.first
}
)
}
//
// // list manga's categories
// 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 ->
// val mangaId = ctx.pathParam("mangaId").toInt()
// val categoryId = ctx.pathParam("categoryId").toInt()
// addMangaToCategory(mangaId, categoryId)
// ctx.status(200)
// }
//
// // removes the manga from the category
// app.delete("api/v1/manga/{mangaId}/category/{categoryId}") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
// val categoryId = ctx.pathParam("categoryId").toInt()
// removeMangaFromCategory(mangaId, categoryId)
// ctx.status(200)
// }
//
// get episode list when showing a anime
app.get("/api/v1/anime/anime/{animeId}/episodes") { ctx ->
val animeId = ctx.pathParam("animeId").toInt()
val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean()
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 ->
val episodeIndex = ctx.pathParam("episodeIndex").toInt()
val animeId = ctx.pathParam("animeId").toInt()
ctx.future(future { getEpisode(episodeIndex, animeId) })
}
// used to modify a episode's parameters
app.patch("/api/v1/anime/anime/{animeId}/episode/{episodeIndex}") { ctx ->
val episodeIndex = ctx.pathParam("episodeIndex").toInt()
val animeId = ctx.pathParam("animeId").toInt()
val read = ctx.formParam("read")?.toBoolean()
val bookmarked = ctx.formParam("bookmarked")?.toBoolean()
val markPrevRead = ctx.formParam("markPrevRead")?.toBoolean()
val lastPageRead = ctx.formParam("lastPageRead")?.toInt()
modifyEpisode(animeId, episodeIndex, read, bookmarked, markPrevRead, lastPageRead)
ctx.status(200)
}
//
// // get page at index "index"
// 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()
//
// ctx.result(
// JavalinSetup.future { getPageImage(mangaId, chapterIndex, index) }
// .thenApply {
// ctx.header("content-type", it.second)
// it.first
// }
// )
// }
//
// // submit a chapter for download
// 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 ->
// // TODO
// }
//
// // global search, Not implemented yet
// 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 ->
val sourceId = ctx.pathParam("sourceId").toLong()
val searchTerm = ctx.pathParam("searchTerm")
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.future(future { sourceSearch(sourceId, searchTerm, pageNum) })
}
//
// // source filter list
// 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 ->
// val mangaId = ctx.pathParam("mangaId").toInt()
//
// ctx.future(
// JavalinSetup.future { addMangaToLibrary(mangaId) }
// )
// }
//
// // removes the manga from the library
// app.delete("api/v1/manga/{mangaId}/library") { ctx ->
// val mangaId = ctx.pathParam("mangaId").toInt()
//
// ctx.future(
// JavalinSetup.future { removeMangaFromLibrary(mangaId) }
// )
// }
//
// // lists mangas that have no category assigned
// app.get("/api/v1/library/") { ctx ->
// ctx.json(getLibraryMangas())
// }
//
// // category list
// app.get("/api/v1/category/") { ctx ->
// ctx.json(Category.getCategoryList())
// }
//
// // category create
// app.post("/api/v1/category/") { ctx ->
// val name = ctx.formParam("name")!!
// Category.createCategory(name)
// ctx.status(200)
// }
//
// // returns some static info of the current app build
// app.get("/api/v1/about/") { ctx ->
// ctx.json(About.getAbout())
// }
//
// // category modification
// app.patch("/api/v1/category/{categoryId}") { ctx ->
// val categoryId = ctx.pathParam("categoryId").toInt()
// val name = ctx.formParam("name")
// val isDefault = ctx.formParam("default")?.toBoolean()
// Category.updateCategory(categoryId, name, isDefault)
// ctx.status(200)
// }
//
// // category re-ordering
// 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()
// Category.reorderCategory(categoryId, from, to)
// ctx.status(200)
// }
//
// // category delete
// 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 ->
// 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.future(
// future {
// restoreLegacyBackup(ctx.bodyAsInputStream())
// }
// )
// }
//
// // 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.future(
// JavalinSetup.future {
// restoreLegacyBackup(ctx.uploadedFile("backup.json")!!.content)
// }
// )
// }
//
// // 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.future(
// JavalinSetup.future {
// createLegacyBackup(
// BackupFlags(
// includeManga = true,
// includeCategories = true,
// includeChapters = true,
// includeTracking = true,
// includeHistory = true,
// )
// )
// }
// )
// }
//
// // returns a Tachiyomi legacy backup json created from the current database as a file
// app.get("/api/v1/backup/legacy/export/file") { ctx ->
// ctx.contentType("application/json")
// val sdf = SimpleDateFormat("yyyy-MM-dd_HH-mm")
// val currentDate = sdf.format(Date())
//
// ctx.header("Content-Disposition", "attachment; filename=\"tachidesk_$currentDate.json\"")
// ctx.future(
// JavalinSetup.future {
// createLegacyBackup(
// BackupFlags(
// includeManga = true,
// includeCategories = true,
// includeChapters = true,
// includeTracking = true,
// includeHistory = true,
// )
// )
// }
// )
// }
//
// // Download queue stats
// app.ws("/api/v1/downloads") { ws ->
// ws.onConnect { ctx ->
// // TODO: send current stat
// // TODO: add to downlad subscribers
// }
// ws.onMessage {
// // TODO: send current stat
// }
// ws.onClose { ctx ->
// // TODO: remove from subscribers
// }
// }
}
}
@@ -1,139 +0,0 @@
package suwayomi.tachidesk.anime.impl
/*
* 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 eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.network.GET
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.anime.impl.AnimeList.proxyThumbnailUrl
import suwayomi.tachidesk.anime.impl.Source.getAnimeSource
import suwayomi.tachidesk.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource
import suwayomi.tachidesk.anime.model.dataclass.AnimeDataClass
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.ImageResponse.clearCachedImage
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getCachedImageResponse
import suwayomi.tachidesk.server.ApplicationDirs
import java.io.InputStream
object Anime {
private fun truncate(text: String?, maxLength: Int): String? {
return if (text?.length ?: 0 > maxLength)
text?.take(maxLength - 3) + "..."
else
text
}
suspend fun getAnime(animeId: Int, onlineFetch: Boolean = false): AnimeDataClass {
var animeEntry = transaction { AnimeTable.select { AnimeTable.id eq animeId }.first() }
return if (animeEntry[AnimeTable.initialized] && !onlineFetch) {
AnimeDataClass(
animeId,
animeEntry[AnimeTable.sourceReference].toString(),
animeEntry[AnimeTable.url],
animeEntry[AnimeTable.title],
proxyThumbnailUrl(animeId),
true,
animeEntry[AnimeTable.artist],
animeEntry[AnimeTable.author],
animeEntry[AnimeTable.description],
animeEntry[AnimeTable.genre],
AnimeStatus.valueOf(animeEntry[AnimeTable.status]).name,
animeEntry[AnimeTable.inLibrary],
getAnimeSource(animeEntry[AnimeTable.sourceReference]),
false
)
} else { // initialize anime
val source = getAnimeHttpSource(animeEntry[AnimeTable.sourceReference])
val fetchedAnime = source.fetchAnimeDetails(
SAnime.create().apply {
url = animeEntry[AnimeTable.url]
title = animeEntry[AnimeTable.title]
}
).awaitSingle()
transaction {
AnimeTable.update({ AnimeTable.id eq animeId }) {
it[AnimeTable.initialized] = true
it[AnimeTable.artist] = fetchedAnime.artist
it[AnimeTable.author] = fetchedAnime.author
it[AnimeTable.description] = truncate(fetchedAnime.description, 4096)
it[AnimeTable.genre] = fetchedAnime.genre
it[AnimeTable.status] = fetchedAnime.status
if (fetchedAnime.thumbnail_url != null && fetchedAnime.thumbnail_url.orEmpty().isNotEmpty())
it[AnimeTable.thumbnail_url] = fetchedAnime.thumbnail_url
}
}
clearAnimeThumbnail(animeId)
animeEntry = transaction { AnimeTable.select { AnimeTable.id eq animeId }.first() }
AnimeDataClass(
animeId,
animeEntry[AnimeTable.sourceReference].toString(),
animeEntry[AnimeTable.url],
animeEntry[AnimeTable.title],
proxyThumbnailUrl(animeId),
true,
fetchedAnime.artist,
fetchedAnime.author,
fetchedAnime.description,
fetchedAnime.genre,
AnimeStatus.valueOf(fetchedAnime.status).name,
animeEntry[AnimeTable.inLibrary],
getAnimeSource(animeEntry[AnimeTable.sourceReference]),
true
)
}
}
private val applicationDirs by DI.global.instance<ApplicationDirs>()
suspend fun getAnimeThumbnail(animeId: Int): Pair<InputStream, String> {
val saveDir = applicationDirs.animeThumbnailsRoot
val fileName = animeId.toString()
return getCachedImageResponse(saveDir, fileName) {
getAnime(animeId) // make sure is initialized
val animeEntry = transaction { AnimeTable.select { AnimeTable.id eq animeId }.first() }
val sourceId = animeEntry[AnimeTable.sourceReference]
val source = getAnimeHttpSource(sourceId)
val thumbnailUrl = animeEntry[AnimeTable.thumbnail_url]!!
source.client.newCall(
GET(thumbnailUrl, source.headers)
).await()
}
}
private fun clearAnimeThumbnail(animeId: Int) {
val saveDir = applicationDirs.animeThumbnailsRoot
val fileName = animeId.toString()
clearCachedImage(saveDir, fileName)
}
}
@@ -1,102 +0,0 @@
package suwayomi.tachidesk.anime.impl
/*
* 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 eu.kanade.tachiyomi.animesource.model.AnimesPage
import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource
import suwayomi.tachidesk.anime.model.dataclass.AnimeDataClass
import suwayomi.tachidesk.anime.model.dataclass.PagedAnimeListDataClass
import suwayomi.tachidesk.anime.model.table.AnimeStatus
import suwayomi.tachidesk.anime.model.table.AnimeTable
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
object AnimeList {
fun proxyThumbnailUrl(animeId: Int): String {
return "/api/v1/anime/anime/$animeId/thumbnail"
}
suspend fun getAnimeList(sourceId: Long, pageNum: Int = 1, popular: Boolean): PagedAnimeListDataClass {
val source = getAnimeHttpSource(sourceId)
val animesPage = if (popular) {
source.fetchPopularAnime(pageNum).awaitSingle()
} else {
if (source.supportsLatest)
source.fetchLatestUpdates(pageNum).awaitSingle()
else
throw Exception("Source $source doesn't support latest")
}
return animesPage.processEntries(sourceId)
}
fun AnimesPage.processEntries(sourceId: Long): PagedAnimeListDataClass {
val animesPage = this
val animeList = transaction {
return@transaction animesPage.animes.map { anime ->
val animeEntry = AnimeTable.select { AnimeTable.url eq anime.url }.firstOrNull()
if (animeEntry == null) { // create anime entry
val animeId = AnimeTable.insertAndGetId {
it[url] = anime.url
it[title] = anime.title
it[artist] = anime.artist
it[author] = anime.author
it[description] = anime.description
it[genre] = anime.genre
it[status] = anime.status
it[thumbnail_url] = anime.thumbnail_url
it[sourceReference] = sourceId
}.value
AnimeDataClass(
animeId,
sourceId.toString(),
anime.url,
anime.title,
proxyThumbnailUrl(animeId),
anime.initialized,
anime.artist,
anime.author,
anime.description,
anime.genre,
AnimeStatus.valueOf(anime.status).name
)
} else {
val animeId = animeEntry[AnimeTable.id].value
AnimeDataClass(
animeId,
sourceId.toString(),
anime.url,
anime.title,
proxyThumbnailUrl(animeId),
true,
animeEntry[AnimeTable.artist],
animeEntry[AnimeTable.author],
animeEntry[AnimeTable.description],
animeEntry[AnimeTable.genre],
AnimeStatus.valueOf(animeEntry[AnimeTable.status]).name,
animeEntry[AnimeTable.inLibrary]
)
}
}
}
return PagedAnimeListDataClass(
animeList,
animesPage.hasNextPage
)
}
}
@@ -1,248 +0,0 @@
package suwayomi.tachidesk.anime.impl
/*
* 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 eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import org.jetbrains.exposed.sql.SortOrder.DESC
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.anime.impl.Anime.getAnime
import suwayomi.tachidesk.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource
import suwayomi.tachidesk.anime.model.dataclass.EpisodeDataClass
import suwayomi.tachidesk.anime.model.dataclass.VideoDataClass
import suwayomi.tachidesk.anime.model.table.AnimeTable
import suwayomi.tachidesk.anime.model.table.EpisodeTable
import suwayomi.tachidesk.anime.model.table.toDataClass
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
object Episode {
/** get episode list when showing an anime */
suspend fun getEpisodeList(animeId: Int, onlineFetch: Boolean?): List<EpisodeDataClass> {
return if (onlineFetch == true) {
getSourceEpisodes(animeId)
} else {
transaction {
EpisodeTable.select { EpisodeTable.anime eq animeId }.orderBy(EpisodeTable.episodeIndex to DESC)
.map {
EpisodeTable.toDataClass(it)
}
}.ifEmpty {
// If it was explicitly set to offline dont grab episodes
if (onlineFetch == null) {
getSourceEpisodes(animeId)
} else emptyList()
}
}
}
private suspend fun getSourceEpisodes(animeId: Int): List<EpisodeDataClass> {
val animeDetails = getAnime(animeId)
val source = getAnimeHttpSource(animeDetails.sourceId.toLong())
val episodeList = source.fetchEpisodeList(
SAnime.create().apply {
title = animeDetails.title
url = animeDetails.url
}
).awaitSingle()
val episodeCount = episodeList.count()
transaction {
episodeList.reversed().forEachIndexed { index, fetchedEpisode ->
val episodeEntry = EpisodeTable.select { EpisodeTable.url eq fetchedEpisode.url }.firstOrNull()
if (episodeEntry == null) {
EpisodeTable.insert {
it[url] = fetchedEpisode.url
it[name] = fetchedEpisode.name
it[date_upload] = fetchedEpisode.date_upload
it[episode_number] = fetchedEpisode.episode_number
it[scanlator] = fetchedEpisode.scanlator
it[episodeIndex] = index + 1
it[anime] = animeId
}
} else {
EpisodeTable.update({ EpisodeTable.url eq fetchedEpisode.url }) {
it[name] = fetchedEpisode.name
it[date_upload] = fetchedEpisode.date_upload
it[episode_number] = fetchedEpisode.episode_number
it[scanlator] = fetchedEpisode.scanlator
it[episodeIndex] = index + 1
it[anime] = animeId
}
}
}
}
// clear any orphaned episodes that are in the db but not in `episodeList`
val dbEpisodeCount = transaction { EpisodeTable.select { EpisodeTable.anime eq animeId }.count() }
if (dbEpisodeCount > episodeCount) { // we got some clean up due
val dbEpisodeList = transaction { EpisodeTable.select { EpisodeTable.anime eq animeId } }
dbEpisodeList.forEach {
if (it[EpisodeTable.episodeIndex] >= episodeList.size ||
episodeList[it[EpisodeTable.episodeIndex] - 1].url != it[EpisodeTable.url]
) {
transaction {
// PageTable.deleteWhere { PageTable.episode eq it[EpisodeTable.id] }
EpisodeTable.deleteWhere { EpisodeTable.id eq it[EpisodeTable.id] }
}
}
}
}
val dbEpisodeMap = transaction {
EpisodeTable.select { EpisodeTable.anime eq animeId }
.associateBy({ it[EpisodeTable.url] }, { it })
}
return episodeList.mapIndexed { index, it ->
val dbEpisode = dbEpisodeMap.getValue(it.url)
EpisodeDataClass(
it.url,
it.name,
it.date_upload,
it.episode_number,
it.scanlator,
animeId,
dbEpisode[EpisodeTable.isRead],
dbEpisode[EpisodeTable.isBookmarked],
dbEpisode[EpisodeTable.lastPageRead],
episodeCount - index,
episodeList.size
)
}
}
/** used to display a episode, get a episode in order to show it's video */
suspend fun getEpisode(episodeIndex: Int, animeId: Int): EpisodeDataClass {
val episode = getEpisodeList(animeId, false)
.first { it.index == episodeIndex }
val animeEntry = transaction { AnimeTable.select { AnimeTable.id eq animeId }.first() }
val source = getAnimeHttpSource(animeEntry[AnimeTable.sourceReference])
val fetchedVideos = source.fetchVideoList(
SEpisode.create().also {
it.url = episode.url
it.name = episode.name
}
).awaitSingle()
return EpisodeDataClass(
episode.url,
episode.name,
episode.uploadDate,
episode.episodeNumber,
episode.scanlator,
animeId,
episode.read,
episode.bookmarked,
episode.lastPageRead,
episode.index,
episode.episodeCount,
fetchedVideos.map {
VideoDataClass(
it.url,
it.quality,
it.videoUrl,
)
}
)
}
// /** used to display a episode, get a episode in order to show it's pages */
// suspend fun getEpisode(episodeIndex: Int, animeId: Int): EpisodeDataClass {
// val episodeEntry = transaction {
// EpisodeTable.select {
// (EpisodeTable.episodeIndex eq episodeIndex) and (EpisodeTable.anime eq animeId)
// }.first()
// }
// val animeEntry = transaction { MangaTable.select { MangaTable.id eq animeId }.first() }
// val source = getAnimeHttpSource(animeEntry[MangaTable.sourceReference])
//
// val pageList = source.fetchPageList(
// SEpisode.create().apply {
// url = episodeEntry[EpisodeTable.url]
// name = episodeEntry[EpisodeTable.name]
// }
// ).awaitSingle()
//
// val episodeId = episodeEntry[EpisodeTable.id].value
// val episodeCount = transaction { EpisodeTable.select { EpisodeTable.anime eq animeId }.count() }
//
// // update page list for this episode
// transaction {
// pageList.forEach { page ->
// val pageEntry = transaction { PageTable.select { (PageTable.episode eq episodeId) and (PageTable.index eq page.index) }.firstOrNull() }
// if (pageEntry == null) {
// PageTable.insert {
// it[index] = page.index
// it[url] = page.url
// it[imageUrl] = page.imageUrl
// it[episode] = episodeId
// }
// } else {
// PageTable.update({ (PageTable.episode eq episodeId) and (PageTable.index eq page.index) }) {
// it[url] = page.url
// it[imageUrl] = page.imageUrl
// }
// }
// }
// }
//
// return EpisodeDataClass(
// episodeEntry[EpisodeTable.url],
// episodeEntry[EpisodeTable.name],
// episodeEntry[EpisodeTable.date_upload],
// episodeEntry[EpisodeTable.episode_number],
// episodeEntry[EpisodeTable.scanlator],
// animeId,
// episodeEntry[EpisodeTable.isRead],
// episodeEntry[EpisodeTable.isBookmarked],
// episodeEntry[EpisodeTable.lastPageRead],
//
// episodeEntry[EpisodeTable.episodeIndex],
// episodeCount.toInt(),
// pageList.count()
// )
// }
fun modifyEpisode(animeId: Int, episodeIndex: Int, isRead: Boolean?, isBookmarked: Boolean?, markPrevRead: Boolean?, lastPageRead: Int?) {
transaction {
if (listOf(isRead, isBookmarked, lastPageRead).any { it != null }) {
EpisodeTable.update({ (EpisodeTable.anime eq animeId) and (EpisodeTable.episodeIndex eq episodeIndex) }) { update ->
isRead?.also {
update[EpisodeTable.isRead] = it
}
isBookmarked?.also {
update[EpisodeTable.isBookmarked] = it
}
lastPageRead?.also {
update[EpisodeTable.lastPageRead] = it
}
}
}
markPrevRead?.let {
EpisodeTable.update({ (EpisodeTable.anime eq animeId) and (EpisodeTable.episodeIndex less episodeIndex) }) {
it[EpisodeTable.isRead] = markPrevRead
}
}
}
}
}
@@ -1,21 +0,0 @@
package suwayomi.tachidesk.anime.impl
/*
* 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 suwayomi.tachidesk.anime.impl.AnimeList.processEntries
import suwayomi.tachidesk.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource
import suwayomi.tachidesk.anime.model.dataclass.PagedAnimeListDataClass
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
object Search {
suspend fun sourceSearch(sourceId: Long, searchTerm: String, pageNum: Int): PagedAnimeListDataClass {
val source = getAnimeHttpSource(sourceId)
val searchManga = source.fetchSearchAnime(pageNum, searchTerm, source.getFilterList()).awaitSingle()
return searchManga.processEntries(sourceId)
}
}
@@ -1,50 +0,0 @@
package suwayomi.tachidesk.anime.impl
/*
* 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 mu.KotlinLogging
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.anime.impl.extension.Extension.getExtensionIconUrl
import suwayomi.tachidesk.anime.impl.util.GetAnimeHttpSource.getAnimeHttpSource
import suwayomi.tachidesk.anime.model.dataclass.AnimeSourceDataClass
import suwayomi.tachidesk.anime.model.table.AnimeExtensionTable
import suwayomi.tachidesk.anime.model.table.AnimeSourceTable
object Source {
private val logger = KotlinLogging.logger {}
fun getSourceList(): List<AnimeSourceDataClass> {
return transaction {
AnimeSourceTable.selectAll().map {
AnimeSourceDataClass(
it[AnimeSourceTable.id].value.toString(),
it[AnimeSourceTable.name],
it[AnimeSourceTable.lang],
getExtensionIconUrl(AnimeExtensionTable.select { AnimeExtensionTable.id eq it[AnimeSourceTable.extension] }.first()[AnimeExtensionTable.apkName]),
getAnimeHttpSource(it[AnimeSourceTable.id].value).supportsLatest
)
}
}
}
fun getAnimeSource(sourceId: Long): AnimeSourceDataClass {
return transaction {
val source = AnimeSourceTable.select { AnimeSourceTable.id eq sourceId }.firstOrNull()
AnimeSourceDataClass(
sourceId.toString(),
source?.get(AnimeSourceTable.name),
source?.get(AnimeSourceTable.lang),
source?.let { AnimeExtensionTable.select { AnimeExtensionTable.id eq source[AnimeSourceTable.extension] }.first()[AnimeExtensionTable.iconUrl] },
source?.let { getAnimeHttpSource(sourceId).supportsLatest }
)
}
}
}
@@ -1,251 +0,0 @@
package suwayomi.tachidesk.anime.impl.extension
/*
* 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 android.net.Uri
import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource
import eu.kanade.tachiyomi.animesource.AnimeSource
import eu.kanade.tachiyomi.animesource.AnimeSourceFactory
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import mu.KotlinLogging
import okhttp3.Request
import okio.buffer
import okio.sink
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.anime.impl.extension.ExtensionsList.extensionTableAsDataClass
import suwayomi.tachidesk.anime.impl.extension.github.ExtensionGithubApi
import suwayomi.tachidesk.anime.impl.util.PackageTools.EXTENSION_FEATURE
import suwayomi.tachidesk.anime.impl.util.PackageTools.LIB_VERSION_MAX
import suwayomi.tachidesk.anime.impl.util.PackageTools.LIB_VERSION_MIN
import suwayomi.tachidesk.anime.impl.util.PackageTools.METADATA_NSFW
import suwayomi.tachidesk.anime.impl.util.PackageTools.METADATA_SOURCE_CLASS
import suwayomi.tachidesk.anime.impl.util.PackageTools.dex2jar
import suwayomi.tachidesk.anime.impl.util.PackageTools.getPackageInfo
import suwayomi.tachidesk.anime.impl.util.PackageTools.getSignatureHash
import suwayomi.tachidesk.anime.impl.util.PackageTools.loadExtensionSources
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.ImageResponse.getCachedImageResponse
import suwayomi.tachidesk.server.ApplicationDirs
import uy.kohesive.injekt.injectLazy
import java.io.File
import java.io.InputStream
object Extension {
private val logger = KotlinLogging.logger {}
private val applicationDirs by DI.global.instance<ApplicationDirs>()
data class InstallableAPK(
val apkFilePath: String,
val pkgName: String
)
suspend fun installExtension(pkgName: String): Int {
logger.debug("Installing $pkgName")
val extensionRecord = extensionTableAsDataClass().first { it.pkgName == pkgName }
return installAPK {
val apkURL = ExtensionGithubApi.getApkUrl(extensionRecord)
val apkName = Uri.parse(apkURL).lastPathSegment!!
val apkSavePath = "${applicationDirs.extensionsRoot}/$apkName"
// download apk file
downloadAPKFile(apkURL, apkSavePath)
apkSavePath
}
}
suspend fun installAPK(fetcher: suspend () -> String): Int {
val apkFilePath = fetcher()
val apkName = File(apkFilePath).name
// check if we don't have the extension already installed
// if it's installed and we want to update, it first has to be uninstalled
val isInstalled = transaction {
AnimeExtensionTable.select { AnimeExtensionTable.apkName eq apkName }.firstOrNull()
}?.get(AnimeExtensionTable.isInstalled) ?: false
if (!isInstalled) {
val fileNameWithoutType = apkName.substringBefore(".apk")
val dirPathWithoutType = "${applicationDirs.extensionsRoot}/$fileNameWithoutType"
val jarFilePath = "$dirPathWithoutType.jar"
val dexFilePath = "$dirPathWithoutType.dex"
val packageInfo = getPackageInfo(apkFilePath)
val pkgName = packageInfo.packageName
if (!packageInfo.reqFeatures.orEmpty().any { it.name == EXTENSION_FEATURE }) {
throw Exception("This apk is not a Tachiyomi extension")
}
// Validate lib version
val libVersion = packageInfo.versionName.substringBeforeLast('.').toDouble()
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
throw Exception(
"Lib version is $libVersion, while only versions " +
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed"
)
}
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")
}
val isNsfw = packageInfo.applicationInfo.metaData.getString(METADATA_NSFW) == "1"
val className = packageInfo.packageName + packageInfo.applicationInfo.metaData.getString(METADATA_SOURCE_CLASS)
logger.debug("Main class for extension is $className")
dex2jar(apkFilePath, jarFilePath, fileNameWithoutType)
// clean up
// File(apkFilePath).delete()
File(dexFilePath).delete()
// collect sources from the extension
val sources: List<AnimeCatalogueSource> = when (val instance = loadExtensionSources(jarFilePath, className)) {
is AnimeSource -> listOf(instance)
is AnimeSourceFactory -> instance.createSources()
else -> throw RuntimeException("Unknown source class type! ${instance.javaClass}")
}.map { it as AnimeCatalogueSource }
val langs = sources.map { it.lang }.toSet()
val extensionLang = when (langs.size) {
0 -> ""
1 -> langs.first()
else -> "all"
}
val extensionName = packageInfo.applicationInfo.nonLocalizedLabel.toString().substringAfter("Aniyomi: ")
// update extension info
transaction {
if (AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq pkgName }.firstOrNull() == null) {
AnimeExtensionTable.insert {
it[this.apkName] = apkName
it[name] = extensionName
it[this.pkgName] = packageInfo.packageName
it[versionName] = packageInfo.versionName
it[versionCode] = packageInfo.versionCode
it[lang] = extensionLang
it[this.isNsfw] = isNsfw
}
}
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq pkgName }) {
it[this.isInstalled] = true
it[this.classFQName] = className
}
val extensionId = AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq pkgName }.first()[AnimeExtensionTable.id].value
sources.forEach { httpSource ->
AnimeSourceTable.insert {
it[id] = httpSource.id
it[name] = httpSource.name
it[lang] = httpSource.lang
it[extension] = extensionId
}
logger.debug("Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}")
}
}
return 201 // we installed successfully
} else {
return 302 // extension was already installed
}
}
private val network: NetworkHelper by injectLazy()
private suspend fun downloadAPKFile(url: String, savePath: String) {
val request = Request.Builder().url(url).build()
val response = network.client.newCall(request).await()
val downloadedFile = File(savePath)
downloadedFile.sink().buffer().use { sink ->
response.body!!.source().use { source ->
sink.writeAll(source)
sink.flush()
}
}
}
fun uninstallExtension(pkgName: String) {
logger.debug("Uninstalling $pkgName")
val extensionRecord = transaction { AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq pkgName }.first() }
val fileNameWithoutType = extensionRecord[AnimeExtensionTable.apkName].substringBefore(".apk")
val jarPath = "${applicationDirs.extensionsRoot}/$fileNameWithoutType.jar"
transaction {
val extensionId = extensionRecord[AnimeExtensionTable.id].value
AnimeSourceTable.deleteWhere { AnimeSourceTable.extension eq extensionId }
if (extensionRecord[AnimeExtensionTable.isObsolete])
AnimeExtensionTable.deleteWhere { AnimeExtensionTable.pkgName eq pkgName }
else
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq pkgName }) {
it[isInstalled] = false
}
}
if (File(jarPath).exists()) {
File(jarPath).delete()
}
}
suspend fun updateExtension(pkgName: String): Int {
val targetExtension = ExtensionsList.updateMap.remove(pkgName)!!
uninstallExtension(pkgName)
transaction {
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq pkgName }) {
it[name] = targetExtension.name
it[versionName] = targetExtension.versionName
it[versionCode] = targetExtension.versionCode
it[lang] = targetExtension.lang
it[isNsfw] = targetExtension.isNsfw
it[apkName] = targetExtension.apkName
it[iconUrl] = targetExtension.iconUrl
it[hasUpdate] = false
}
}
return installExtension(pkgName)
}
suspend fun getExtensionIcon(apkName: String): Pair<InputStream, String> {
val iconUrl = transaction { AnimeExtensionTable.select { AnimeExtensionTable.apkName eq apkName }.first() }[AnimeExtensionTable.iconUrl]
val saveDir = "${applicationDirs.extensionsRoot}/icon"
return getCachedImageResponse(saveDir, apkName) {
network.client.newCall(
GET(iconUrl)
).await()
}
}
fun getExtensionIconUrl(apkName: String): String {
return "/api/v1/anime/extension/icon/$apkName"
}
}
@@ -1,132 +0,0 @@
package suwayomi.tachidesk.anime.impl.extension
/*
* 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 mu.KotlinLogging
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.anime.impl.extension.Extension.getExtensionIconUrl
import suwayomi.tachidesk.anime.impl.extension.github.ExtensionGithubApi
import suwayomi.tachidesk.anime.impl.extension.github.OnlineExtension
import suwayomi.tachidesk.anime.model.dataclass.AnimeExtensionDataClass
import suwayomi.tachidesk.anime.model.table.AnimeExtensionTable
import java.util.concurrent.ConcurrentHashMap
object ExtensionsList {
private val logger = KotlinLogging.logger {}
var lastUpdateCheck: Long = 0
var updateMap = ConcurrentHashMap<String, OnlineExtension>()
/** 60,000 milliseconds = 60 seconds */
private const val ExtensionUpdateDelayTime = 60 * 1000
suspend fun getExtensionList(): List<AnimeExtensionDataClass> {
// update if {ExtensionUpdateDelayTime} seconds has passed or requested offline and database is empty
if (lastUpdateCheck + ExtensionUpdateDelayTime < System.currentTimeMillis()) {
logger.debug("Getting extensions list from the internet")
lastUpdateCheck = System.currentTimeMillis()
val foundExtensions = ExtensionGithubApi.findExtensions()
updateExtensionDatabase(foundExtensions)
} else {
logger.debug("used cached extension list")
}
return extensionTableAsDataClass()
}
fun extensionTableAsDataClass() = transaction {
AnimeExtensionTable.selectAll().map {
AnimeExtensionDataClass(
it[AnimeExtensionTable.apkName],
getExtensionIconUrl(it[AnimeExtensionTable.apkName]),
it[AnimeExtensionTable.name],
it[AnimeExtensionTable.pkgName],
it[AnimeExtensionTable.versionName],
it[AnimeExtensionTable.versionCode],
it[AnimeExtensionTable.lang],
it[AnimeExtensionTable.isNsfw],
it[AnimeExtensionTable.isInstalled],
it[AnimeExtensionTable.hasUpdate],
it[AnimeExtensionTable.isObsolete],
)
}
}
private fun updateExtensionDatabase(foundExtensions: List<OnlineExtension>) {
transaction {
foundExtensions.forEach { foundExtension ->
val extensionRecord = AnimeExtensionTable.select { AnimeExtensionTable.pkgName eq foundExtension.pkgName }.firstOrNull()
if (extensionRecord != null) {
if (extensionRecord[AnimeExtensionTable.isInstalled]) {
when {
foundExtension.versionCode > extensionRecord[AnimeExtensionTable.versionCode] -> {
// there is an update
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq foundExtension.pkgName }) {
it[hasUpdate] = true
}
updateMap.putIfAbsent(foundExtension.pkgName, foundExtension)
}
foundExtension.versionCode < extensionRecord[AnimeExtensionTable.versionCode] -> {
// some how the user installed an invalid version
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq foundExtension.pkgName }) {
it[isObsolete] = true
}
}
}
} else {
// extension is not installed so we can overwrite the data without a care
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq foundExtension.pkgName }) {
it[name] = foundExtension.name
it[versionName] = foundExtension.versionName
it[versionCode] = foundExtension.versionCode
it[lang] = foundExtension.lang
it[isNsfw] = foundExtension.isNsfw
it[apkName] = foundExtension.apkName
it[iconUrl] = foundExtension.iconUrl
}
}
} else {
// insert new record
AnimeExtensionTable.insert {
it[name] = foundExtension.name
it[pkgName] = foundExtension.pkgName
it[versionName] = foundExtension.versionName
it[versionCode] = foundExtension.versionCode
it[lang] = foundExtension.lang
it[isNsfw] = foundExtension.isNsfw
it[apkName] = foundExtension.apkName
it[iconUrl] = foundExtension.iconUrl
}
}
}
// deal with obsolete extensions
AnimeExtensionTable.selectAll().forEach { extensionRecord ->
val foundExtension = foundExtensions.find { it.pkgName == extensionRecord[AnimeExtensionTable.pkgName] }
if (foundExtension == null) {
// not in the repo, so this extensions is obsolete
if (extensionRecord[AnimeExtensionTable.isInstalled]) {
// is installed so we should mark it as obsolete
AnimeExtensionTable.update({ AnimeExtensionTable.pkgName eq extensionRecord[AnimeExtensionTable.pkgName] }) {
it[isObsolete] = true
}
} else {
// is not installed so we can remove the record without a care
AnimeExtensionTable.deleteWhere { AnimeExtensionTable.pkgName eq extensionRecord[AnimeExtensionTable.pkgName] }
}
}
}
}
}
}
@@ -1,79 +0,0 @@
package suwayomi.tachidesk.anime.impl.extension.github
/*
* 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 com.github.salomonbrys.kotson.int
import com.github.salomonbrys.kotson.string
import com.google.gson.JsonArray
import com.google.gson.JsonParser
import eu.kanade.tachiyomi.network.NetworkHelper
import okhttp3.Request
import suwayomi.tachidesk.anime.impl.util.PackageTools.LIB_VERSION_MAX
import suwayomi.tachidesk.anime.impl.util.PackageTools.LIB_VERSION_MIN
import suwayomi.tachidesk.anime.model.dataclass.AnimeExtensionDataClass
import suwayomi.tachidesk.manga.impl.util.network.UnzippingInterceptor
import uy.kohesive.injekt.injectLazy
object ExtensionGithubApi {
private const val BASE_URL = "https://raw.githubusercontent.com"
private const val REPO_URL_PREFIX = "$BASE_URL/jmir1/tachiyomi-extensions/repo"
private fun parseResponse(json: JsonArray): List<OnlineExtension> {
return json
.map { it.asJsonObject }
.filter { element ->
val versionName = element["version"].string
val libVersion = versionName.substringBeforeLast('.').toInt()
libVersion in LIB_VERSION_MIN..LIB_VERSION_MAX
}
.map { element ->
val name = element["name"].string.substringAfter("Aniyomi: ")
val pkgName = element["pkg"].string
val apkName = element["apk"].string
val versionName = element["version"].string
val versionCode = element["code"].int
val lang = element["lang"].string
val nsfw = element["nsfw"].int == 1
val icon = "$REPO_URL_PREFIX/icon/${apkName.replace(".apk", ".png")}"
OnlineExtension(name, pkgName, versionName, versionCode, lang, nsfw, apkName, icon)
}
}
suspend fun findExtensions(): List<OnlineExtension> {
val response = getRepo()
return parseResponse(response)
}
fun getApkUrl(extension: AnimeExtensionDataClass): String {
return "$REPO_URL_PREFIX/apk/${extension.apkName}"
}
private val client by lazy {
val network: NetworkHelper by injectLazy()
network.client.newBuilder()
.addNetworkInterceptor { chain ->
val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder()
.header("Content-Encoding", "gzip")
.header("Content-Type", "application/json")
.build()
}
.addInterceptor(UnzippingInterceptor())
.build()
}
private fun getRepo(): JsonArray {
val request = Request.Builder()
.url("$REPO_URL_PREFIX/index.json.gz")
.build()
val response = client.newCall(request).execute().use { response -> response.body!!.string() }
return JsonParser.parseString(response).asJsonArray
}
}
@@ -1,19 +0,0 @@
package suwayomi.tachidesk.anime.impl.extension.github
/*
* 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 OnlineExtension(
val name: String,
val pkgName: String,
val versionName: String,
val versionCode: Int,
val lang: String,
val isNsfw: Boolean,
val apkName: String,
val iconUrl: String
)
@@ -1,57 +0,0 @@
package suwayomi.tachidesk.anime.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 eu.kanade.tachiyomi.animesource.AnimeSource
import eu.kanade.tachiyomi.animesource.AnimeSourceFactory
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
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.anime.impl.util.PackageTools.loadExtensionSources
import suwayomi.tachidesk.anime.model.table.AnimeExtensionTable
import suwayomi.tachidesk.anime.model.table.AnimeSourceTable
import suwayomi.tachidesk.server.ApplicationDirs
import java.util.concurrent.ConcurrentHashMap
object GetAnimeHttpSource {
private val sourceCache = ConcurrentHashMap<Long, AnimeHttpSource>()
private val applicationDirs by DI.global.instance<ApplicationDirs>()
fun getAnimeHttpSource(sourceId: Long): AnimeHttpSource {
val cachedResult: AnimeHttpSource? = sourceCache[sourceId]
if (cachedResult != null) {
return cachedResult
}
val sourceRecord = transaction {
AnimeSourceTable.select { AnimeSourceTable.id eq sourceId }.first()
}
val extensionId = sourceRecord[AnimeSourceTable.extension]
val extensionRecord = transaction {
AnimeExtensionTable.select { AnimeExtensionTable.id eq extensionId }.first()
}
val apkName = extensionRecord[AnimeExtensionTable.apkName]
val className = extensionRecord[AnimeExtensionTable.classFQName]
val jarName = apkName.substringBefore(".apk") + ".jar"
val jarPath = "${applicationDirs.extensionsRoot}/$jarName"
when (val instance = loadExtensionSources(jarPath, className)) {
is AnimeSource -> listOf(instance)
is AnimeSourceFactory -> instance.createSources()
else -> throw Exception("Unknown source class type! ${instance.javaClass}")
}.forEach {
sourceCache[it.id] = it as AnimeHttpSource
}
return sourceCache[sourceId]!!
}
}
@@ -1,148 +0,0 @@
package suwayomi.tachidesk.anime.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 android.content.pm.PackageInfo
import android.content.pm.Signature
import android.os.Bundle
import com.googlecode.d2j.dex.Dex2jar
import com.googlecode.d2j.reader.MultiDexFileReader
import com.googlecode.dex2jar.tools.BaksmaliBaseDexExceptionHandler
import eu.kanade.tachiyomi.util.lang.Hash
import mu.KotlinLogging
import net.dongliu.apk.parser.ApkFile
import net.dongliu.apk.parser.ApkParsers
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import org.w3c.dom.Element
import org.w3c.dom.Node
import suwayomi.tachidesk.manga.impl.util.BytecodeEditor
import suwayomi.tachidesk.server.ApplicationDirs
import xyz.nulldev.androidcompat.pm.InstalledPackage.Companion.toList
import xyz.nulldev.androidcompat.pm.toPackageInfo
import java.io.File
import java.net.URL
import java.net.URLClassLoader
import java.nio.file.Files
import java.nio.file.Path
import javax.xml.parsers.DocumentBuilderFactory
object PackageTools {
private val logger = KotlinLogging.logger {}
private val applicationDirs by DI.global.instance<ApplicationDirs>()
const val EXTENSION_FEATURE = "tachiyomi.animeextension"
const val METADATA_SOURCE_CLASS = "tachiyomi.animeextension.class"
const val METADATA_SOURCE_FACTORY = "tachiyomi.animeextension.factory"
const val METADATA_NSFW = "tachiyomi.animeextension.nsfw"
const val LIB_VERSION_MIN = 12
const val LIB_VERSION_MAX = 12
private const val officialSignature = "50ab1d1e3a20d204d0ad6d334c7691c632e41b98dfa132bf385695fdfa63839c" // jmir1's key
var trustedSignatures = mutableSetOf<String>() + officialSignature
/**
* Convert dex to jar, a wrapper for the dex2jar library
*/
fun dex2jar(dexFile: String, jarFile: String, fileNameWithoutType: String) {
// adopted from com.googlecode.dex2jar.tools.Dex2jarCmd.doCommandLine
// source at: https://github.com/DexPatcher/dex2jar/tree/v2.1-20190905-lanchon/dex-tools/src/main/java/com/googlecode/dex2jar/tools/Dex2jarCmd.java
val jarFilePath = File(jarFile).toPath()
val reader = MultiDexFileReader.open(Files.readAllBytes(File(dexFile).toPath()))
val handler = BaksmaliBaseDexExceptionHandler()
Dex2jar
.from(reader)
.withExceptionHandler(handler)
.reUseReg(false)
.topoLogicalSort()
.skipDebug(true)
.optimizeSynchronized(false)
.printIR(false)
.noCode(false)
.skipExceptions(false)
.to(jarFilePath)
if (handler.hasException()) {
val errorFile: Path = File(applicationDirs.extensionsRoot).toPath().resolve("$fileNameWithoutType-error.txt")
logger.error(
"""
Detail Error Information in File $errorFile
Please report this file to one of following link if possible (any one).
https://sourceforge.net/p/dex2jar/tickets/
https://bitbucket.org/pxb1988/dex2jar/issues
https://github.com/pxb1988/dex2jar/issues
dex2jar@googlegroups.com
""".trimIndent()
)
handler.dump(errorFile, emptyArray<String>())
} else {
BytecodeEditor.fixAndroidClasses(jarFilePath)
}
}
/** A modified version of `xyz.nulldev.androidcompat.pm.InstalledPackage.info` */
fun getPackageInfo(apkFilePath: String): PackageInfo {
val apk = File(apkFilePath)
return ApkParsers.getMetaInfo(apk).toPackageInfo(apk).apply {
val parsed = ApkFile(apk)
val dbFactory = DocumentBuilderFactory.newInstance()
val dBuilder = dbFactory.newDocumentBuilder()
val doc = parsed.manifestXml.byteInputStream().use {
dBuilder.parse(it)
}
logger.debug(parsed.manifestXml)
applicationInfo.metaData = Bundle().apply {
val appTag = doc.getElementsByTagName("application").item(0)
appTag?.childNodes?.toList()
.orEmpty()
.asSequence()
.filter {
it.nodeType == Node.ELEMENT_NODE
}.map {
it as Element
}.filter {
it.tagName == "meta-data"
}.forEach {
putString(
it.attributes.getNamedItem("android:name").nodeValue,
it.attributes.getNamedItem("android:value").nodeValue
)
}
}
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 getSignatureHash(pkgInfo: PackageInfo): String? {
val signatures = pkgInfo.signatures
return if (signatures != null && signatures.isNotEmpty()) {
Hash.sha256(signatures.first().toByteArray())
} else {
null
}
}
/**
* 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")))
val classToLoad = Class.forName(className, false, classLoader)
return classToLoad.getDeclaredConstructor().newInstance()
}
}
@@ -1,36 +0,0 @@
package suwayomi.tachidesk.anime.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/. */
import suwayomi.tachidesk.anime.model.table.AnimeStatus
data class AnimeDataClass(
val id: Int,
val sourceId: String,
val url: String,
val title: String,
val thumbnailUrl: String? = null,
val initialized: Boolean = false,
val artist: String? = null,
val author: String? = null,
val description: String? = null,
val genre: String? = null,
val status: String = AnimeStatus.UNKNOWN.name,
val inLibrary: Boolean = false,
val source: AnimeSourceDataClass? = null,
val freshData: Boolean = false
)
data class PagedAnimeListDataClass(
val mangaList: List<AnimeDataClass>,
val hasNextPage: Boolean
)
@@ -1,24 +0,0 @@
package suwayomi.tachidesk.anime.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 AnimeExtensionDataClass(
val apkName: String,
val iconUrl: String,
val name: String,
val pkgName: String,
val versionName: String,
val versionCode: Int,
val lang: String,
val isNsfw: Boolean,
val installed: Boolean,
val hasUpdate: Boolean,
val obsolete: Boolean,
)
@@ -1,35 +0,0 @@
package suwayomi.tachidesk.anime.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 EpisodeDataClass(
val url: String,
val name: String,
val uploadDate: Long,
val episodeNumber: Float,
val scanlator: String?,
val animeId: Int,
/** chapter is read */
val read: Boolean,
/** chapter is bookmarked */
val bookmarked: Boolean,
/** last read page, zero means not read/no data */
val lastPageRead: Int,
/** this chapter's index, starts with 1 */
val index: Int,
/** total episode count, used to calculate if there's a next and prev episode */
val episodeCount: Int? = null,
/** used to construct pages in the front-end */
val videos: List<VideoDataClass>? = null,
)
@@ -1,31 +0,0 @@
package suwayomi.tachidesk.anime.model.table
/*
* 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.dao.id.IntIdTable
object AnimeExtensionTable : IntIdTable() {
val apkName = varchar("apk_name", 1024)
// default is the local source icon from tachiyomi
val iconUrl = varchar("icon_url", 2048)
.default("https://raw.githubusercontent.com/tachiyomiorg/tachiyomi/64ba127e7d43b1d7e6d58a6f5c9b2bd5fe0543f7/app/src/main/res/mipmap-xxxhdpi/ic_local_source.webp")
val name = varchar("name", 128)
val pkgName = varchar("pkg_name", 128)
val versionName = varchar("version_name", 16)
val versionCode = integer("version_code")
val lang = varchar("lang", 10)
val isNsfw = bool("is_nsfw")
val isInstalled = bool("is_installed").default(false)
val hasUpdate = bool("has_update").default(false)
val isObsolete = bool("is_obsolete").default(false)
val classFQName = varchar("class_name", 1024).default("") // fully qualified name
}
@@ -1,18 +0,0 @@
package suwayomi.tachidesk.anime.model.table
/*
* 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.dao.id.IdTable
object AnimeSourceTable : IdTable<Long>() {
override val id = long("id").entityId()
val name = varchar("name", 128)
val lang = varchar("lang", 10)
val extension = reference("extension", AnimeExtensionTable)
val partOfFactorySource = bool("part_of_factory_source").default(false)
}
@@ -1,66 +0,0 @@
package suwayomi.tachidesk.anime.model.table
/*
* 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 eu.kanade.tachiyomi.animesource.model.SAnime
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() {
val url = varchar("url", 2048)
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 status = integer("status").default(SAnime.UNKNOWN)
val thumbnail_url = varchar("thumbnail_url", 2048).nullable()
val inLibrary = bool("in_library").default(false)
val defaultCategory = bool("default_category").default(true)
// source is used by some ancestor of IntIdTable
val sourceReference = long("source")
}
fun AnimeTable.toDataClass(mangaEntry: ResultRow) =
MangaDataClass(
mangaEntry[this.id].value,
mangaEntry[sourceReference].toString(),
mangaEntry[url],
mangaEntry[title],
proxyThumbnailUrl(mangaEntry[this.id].value),
mangaEntry[initialized],
mangaEntry[artist],
mangaEntry[author],
mangaEntry[description],
mangaEntry[genre].toGenreList(),
Companion.valueOf(mangaEntry[status]).name,
mangaEntry[inLibrary]
)
enum class AnimeStatus(val status: Int) {
UNKNOWN(0),
ONGOING(1),
COMPLETED(2),
LICENSED(3);
companion object {
fun valueOf(value: Int): AnimeStatus = values().find { it.status == value } ?: UNKNOWN
}
}
@@ -1,46 +0,0 @@
package suwayomi.tachidesk.anime.model.table
/*
* 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.dao.id.IntIdTable
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.anime.model.dataclass.EpisodeDataClass
object EpisodeTable : IntIdTable() {
val url = varchar("url", 2048)
val name = varchar("name", 512)
val date_upload = long("date_upload").default(0)
val episode_number = float("episode_number").default(-1f)
val scanlator = varchar("scanlator", 128).nullable()
val isRead = bool("read").default(false)
val isBookmarked = bool("bookmark").default(false)
val lastPageRead = integer("last_page_read").default(0)
// index is reserved by a function
val episodeIndex = integer("index")
val anime = reference("anime", AnimeTable)
}
fun EpisodeTable.toDataClass(episodeEntry: ResultRow) =
EpisodeDataClass(
episodeEntry[url],
episodeEntry[name],
episodeEntry[date_upload],
episodeEntry[episode_number],
episodeEntry[scanlator],
episodeEntry[anime].value,
episodeEntry[isRead],
episodeEntry[isBookmarked],
episodeEntry[lastPageRead],
episodeEntry[episodeIndex],
transaction { EpisodeTable.select { anime eq episodeEntry[anime] }.count().toInt() }
)
@@ -19,6 +19,7 @@ import suwayomi.tachidesk.manga.controller.DownloadController
import suwayomi.tachidesk.manga.controller.ExtensionController import suwayomi.tachidesk.manga.controller.ExtensionController
import suwayomi.tachidesk.manga.controller.MangaController import suwayomi.tachidesk.manga.controller.MangaController
import suwayomi.tachidesk.manga.controller.SourceController import suwayomi.tachidesk.manga.controller.SourceController
import suwayomi.tachidesk.manga.controller.UpdateController
object MangaAPI { object MangaAPI {
fun defineEndpoints() { fun defineEndpoints() {
@@ -43,14 +44,15 @@ object MangaAPI {
get("{sourceId}/preferences", SourceController::getPreferences) get("{sourceId}/preferences", SourceController::getPreferences)
post("{sourceId}/preferences", SourceController::setPreference) post("{sourceId}/preferences", SourceController::setPreference)
get("{sourceId}/filters", SourceController::filters) get("{sourceId}/filters", SourceController::getFilters)
post("{sourceId}/filters", SourceController::setFilter)
get("{sourceId}/search/{searchTerm}/{pageNum}", SourceController::searchSingle) get("{sourceId}/search", SourceController::searchSingle)
// get("search/{searchTerm}/{pageNum}", SourceController::searchGlobal) // get("all/search", SourceController::searchGlobal) // TODO
} }
path("manga") { path("manga") {
get("{mangaId}", MangaController::retrieve) get("{mangaId}", MangaController.retrieve)
get("{mangaId}/thumbnail", MangaController::thumbnail) get("{mangaId}/thumbnail", MangaController::thumbnail)
get("{mangaId}/category", MangaController::categoryList) get("{mangaId}/category", MangaController::categoryList)
@@ -76,11 +78,13 @@ object MangaAPI {
get("", CategoryController::categoryList) get("", CategoryController::categoryList)
post("", CategoryController::categoryCreate) post("", CategoryController::categoryCreate)
// The order here is important {categoryId} needs to be applied last
// or throws a NumberFormatException
patch("reorder", CategoryController::categoryReorder)
get("{categoryId}", CategoryController::categoryMangas) get("{categoryId}", CategoryController::categoryMangas)
patch("{categoryId}", CategoryController::categoryModify) patch("{categoryId}", CategoryController::categoryModify)
delete("{categoryId}", CategoryController::categoryDelete) delete("{categoryId}", CategoryController::categoryDelete)
patch("reorder", CategoryController::categoryReorder)
} }
path("backup") { path("backup") {
@@ -106,5 +110,12 @@ object MangaAPI {
get("{mangaId}/chapter/{chapterIndex}", DownloadController::queueChapter) get("{mangaId}/chapter/{chapterIndex}", DownloadController::queueChapter)
delete("{mangaId}/chapter/{chapterIndex}", DownloadController::unqueueChapter) delete("{mangaId}/chapter/{chapterIndex}", DownloadController::unqueueChapter)
} }
path("update") {
get("recentChapters/{pageNum}", UpdateController::recentChapters)
post("fetch", UpdateController::categoryUpdate)
get("summary", UpdateController::updateSummary)
ws("", UpdateController::categoryUpdateWS)
}
} }
} }
@@ -10,6 +10,7 @@ package suwayomi.tachidesk.manga.controller
import io.javalin.http.Context import io.javalin.http.Context
import io.javalin.websocket.WsConfig import io.javalin.websocket.WsConfig
import suwayomi.tachidesk.manga.impl.download.DownloadManager import suwayomi.tachidesk.manga.impl.download.DownloadManager
import suwayomi.tachidesk.server.JavalinSetup.future
object DownloadController { object DownloadController {
/** Download queue stats */ /** Download queue stats */
@@ -52,9 +53,11 @@ object DownloadController {
val chapterIndex = ctx.pathParam("chapterIndex").toInt() val chapterIndex = ctx.pathParam("chapterIndex").toInt()
val mangaId = ctx.pathParam("mangaId").toInt() val mangaId = ctx.pathParam("mangaId").toInt()
DownloadManager.enqueue(chapterIndex, mangaId) ctx.future(
future {
ctx.status(200) DownloadManager.enqueue(chapterIndex, mangaId)
}
)
} }
/** delete chapter from download queue */ /** delete chapter from download queue */
@@ -13,20 +13,35 @@ import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.Library import suwayomi.tachidesk.manga.impl.Library
import suwayomi.tachidesk.manga.impl.Manga import suwayomi.tachidesk.manga.impl.Manga
import suwayomi.tachidesk.manga.impl.Page import suwayomi.tachidesk.manga.impl.Page
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.server.JavalinSetup.future import suwayomi.tachidesk.server.JavalinSetup.future
import suwayomi.tachidesk.server.util.handler
import suwayomi.tachidesk.server.util.pathParam
import suwayomi.tachidesk.server.util.queryParam
import suwayomi.tachidesk.server.util.withOperation
object MangaController { object MangaController {
/** get manga info */ /** get manga info */
fun retrieve(ctx: Context) { val retrieve = handler(
val mangaId = ctx.pathParam("mangaId").toInt() pathParam<Int>("mangaId"),
val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean() ?: false queryParam("onlineFetch", false),
documentWith = {
ctx.future( withOperation {
future { summary("Get a manga")
Manga.getManga(mangaId, onlineFetch) description("Get a manga from the database using a specific id")
} }
) },
} behaviorOf = { ctx, mangaId, onlineFetch ->
ctx.future(
future {
Manga.getManga(mangaId, onlineFetch)
}
)
},
withResults = {
json<MangaDataClass>("OK")
}
)
/** manga thumbnail */ /** manga thumbnail */
fun thumbnail(ctx: Context) { fun thumbnail(ctx: Context) {
@@ -37,6 +52,8 @@ object MangaController {
future { Manga.getMangaThumbnail(mangaId, useCache) } future { Manga.getMangaThumbnail(mangaId, useCache) }
.thenApply { .thenApply {
ctx.header("content-type", it.second) ctx.header("content-type", it.second)
val httpCacheSeconds = 60 * 60 * 24
ctx.header("cache-control", "max-age=$httpCacheSeconds")
it.first it.first
} }
) )
@@ -10,6 +10,7 @@ package suwayomi.tachidesk.manga.controller
import io.javalin.http.Context import io.javalin.http.Context
import suwayomi.tachidesk.manga.impl.MangaList import suwayomi.tachidesk.manga.impl.MangaList
import suwayomi.tachidesk.manga.impl.Search import suwayomi.tachidesk.manga.impl.Search
import suwayomi.tachidesk.manga.impl.Search.FilterChange
import suwayomi.tachidesk.manga.impl.Source import suwayomi.tachidesk.manga.impl.Source
import suwayomi.tachidesk.manga.impl.Source.SourcePreferenceChange import suwayomi.tachidesk.manga.impl.Source.SourcePreferenceChange
import suwayomi.tachidesk.server.JavalinSetup.future import suwayomi.tachidesk.server.JavalinSetup.future
@@ -54,7 +55,7 @@ object SourceController {
ctx.json(Source.getSourcePreferences(sourceId)) ctx.json(Source.getSourcePreferences(sourceId))
} }
/** fetch preferences of source with id `sourceId` */ /** set one preference of source with id `sourceId` */
fun setPreference(ctx: Context) { fun setPreference(ctx: Context) {
val sourceId = ctx.pathParam("sourceId").toLong() val sourceId = ctx.pathParam("sourceId").toLong()
val preferenceChange = ctx.bodyAsClass(SourcePreferenceChange::class.java) val preferenceChange = ctx.bodyAsClass(SourcePreferenceChange::class.java)
@@ -62,18 +63,25 @@ object SourceController {
} }
/** fetch filters of source with id `sourceId` */ /** fetch filters of source with id `sourceId` */
fun filters(ctx: Context) { fun getFilters(ctx: Context) {
val sourceId = ctx.pathParam("sourceId").toLong() val sourceId = ctx.pathParam("sourceId").toLong()
val reset = ctx.queryParam("reset")?.toBoolean() ?: false val reset = ctx.queryParam("reset")?.toBoolean() ?: false
ctx.json(Search.getFilterList(sourceId, reset))
}
ctx.json(Search.getInitialFilterList(sourceId, reset)) /** set one filter of source with id `sourceId` */
fun setFilter(ctx: Context) {
val sourceId = ctx.pathParam("sourceId").toLong()
val filterChange = ctx.bodyAsClass(FilterChange::class.java)
ctx.json(Search.setFilter(sourceId, filterChange))
} }
/** single source search */ /** single source search */
fun searchSingle(ctx: Context) { fun searchSingle(ctx: Context) {
val sourceId = ctx.pathParam("sourceId").toLong() val sourceId = ctx.pathParam("sourceId").toLong()
val searchTerm = ctx.pathParam("searchTerm") val searchTerm = ctx.queryParam("searchTerm") ?: ""
val pageNum = ctx.pathParam("pageNum").toInt() val pageNum = ctx.queryParam("pageNum")?.toInt() ?: 1
ctx.future(future { Search.sourceSearch(sourceId, searchTerm, pageNum) }) ctx.future(future { Search.sourceSearch(sourceId, searchTerm, pageNum) })
} }
@@ -0,0 +1,89 @@
package suwayomi.tachidesk.manga.controller
import io.javalin.http.Context
import io.javalin.http.HttpCode
import io.javalin.websocket.WsConfig
import kotlinx.coroutines.runBlocking
import mu.KotlinLogging
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.manga.impl.Category
import suwayomi.tachidesk.manga.impl.CategoryManga
import suwayomi.tachidesk.manga.impl.Chapter
import suwayomi.tachidesk.manga.impl.update.IUpdater
import suwayomi.tachidesk.manga.impl.update.UpdaterSocket
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
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 {
private val logger = KotlinLogging.logger { }
/** get recently updated manga chapters */
fun recentChapters(ctx: Context) {
val pageNum = ctx.pathParam("pageNum").toInt()
ctx.future(
future {
Chapter.getRecentChapters(pageNum)
}
)
}
fun categoryUpdate(ctx: Context) {
val categoryId = ctx.formParam("category")?.toIntOrNull()
val categoriesForUpdate = ArrayList<CategoryDataClass>()
if (categoryId == null) {
logger.info { "Adding Library to Update Queue" }
categoriesForUpdate.addAll(Category.getCategoryList())
} else {
val category = Category.getCategoryById(categoryId)
if (category != null) {
categoriesForUpdate.add(category)
} else {
logger.info { "No Category found" }
ctx.status(HttpCode.BAD_REQUEST)
return
}
}
addCategoriesToUpdateQueue(categoriesForUpdate, true)
ctx.status(HttpCode.OK)
}
private fun addCategoriesToUpdateQueue(categories: List<CategoryDataClass>, clear: Boolean = false) {
val updater by DI.global.instance<IUpdater>()
if (clear) {
runBlocking { updater.reset() }
}
categories.forEach { category ->
val mangas = CategoryManga.getCategoryMangaList(category.id)
mangas.forEach { manga ->
updater.addMangaToQueue(manga)
}
}
}
fun categoryUpdateWS(ws: WsConfig) {
ws.onConnect { ctx ->
UpdaterSocket.addClient(ctx)
}
ws.onMessage { ctx ->
UpdaterSocket.handleRequest(ctx)
}
ws.onClose { ctx ->
UpdaterSocket.removeClient(ctx)
}
}
fun updateSummary(ctx: Context) {
val updater by DI.global.instance<IUpdater>()
ctx.json(updater.getStatus().value.getJsonSummary())
}
}
@@ -10,7 +10,7 @@ package suwayomi.tachidesk.manga.impl
import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
@@ -27,18 +27,21 @@ object Category {
/** /**
* The new category will be placed at the end of the list * The new category will be placed at the end of the list
*/ */
fun createCategory(name: String) { fun createCategory(name: String): Int {
// creating a category named Default is illegal // creating a category named Default is illegal
if (name.equals(DEFAULT_CATEGORY_NAME, ignoreCase = true)) return if (name.equals(DEFAULT_CATEGORY_NAME, ignoreCase = true)) return -1
transaction { return transaction {
if (CategoryTable.select { CategoryTable.name eq name }.firstOrNull() == null) { if (CategoryTable.select { CategoryTable.name eq name }.firstOrNull() == null) {
CategoryTable.insert { val newCategoryId = CategoryTable.insertAndGetId {
it[CategoryTable.name] = name it[CategoryTable.name] = name
it[CategoryTable.order] = Int.MAX_VALUE it[CategoryTable.order] = Int.MAX_VALUE
} }.value
normalizeCategories() normalizeCategories()
}
newCategoryId
} else -1
} }
} }
@@ -106,4 +109,12 @@ object Category {
addDefaultIfNecessary(categories) addDefaultIfNecessary(categories)
} }
} }
fun getCategoryById(categoryId: Int): CategoryDataClass? {
return transaction {
CategoryTable.select { CategoryTable.id eq categoryId }.firstOrNull()?.let {
CategoryTable.toDataClass(it)
}
}
}
} }
@@ -7,19 +7,23 @@ package suwayomi.tachidesk.manga.impl
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import org.jetbrains.exposed.sql.ResultRow
import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.count
import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
import org.jetbrains.exposed.sql.wrapAsExpression
import suwayomi.tachidesk.manga.impl.Category.DEFAULT_CATEGORY_ID import suwayomi.tachidesk.manga.impl.Category.DEFAULT_CATEGORY_ID
import suwayomi.tachidesk.manga.impl.util.lang.isEmpty import suwayomi.tachidesk.manga.impl.util.lang.isEmpty
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.CategoryTable import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.manga.model.table.toDataClass
@@ -56,17 +60,38 @@ object CategoryManga {
* list of mangas that belong to a category * list of mangas that belong to a category
*/ */
fun getCategoryMangaList(categoryId: Int): List<MangaDataClass> { fun getCategoryMangaList(categoryId: Int): List<MangaDataClass> {
val unreadExpression = wrapAsExpression<Long>(
ChapterTable
.slice(ChapterTable.id.count())
.select { (MangaTable.id eq ChapterTable.manga) and (ChapterTable.isRead eq false) }
)
val downloadExpression = wrapAsExpression<Long>(
ChapterTable
.slice(ChapterTable.id.count())
.select { (MangaTable.id eq ChapterTable.manga) and (ChapterTable.isDownloaded eq true) }
)
val selectedColumns = MangaTable.columns + unreadExpression + downloadExpression
val transform: (ResultRow) -> MangaDataClass = {
val dataClass = MangaTable.toDataClass(it)
dataClass.unreadCount = it[unreadExpression]?.toInt()
dataClass.downloadCount = it[downloadExpression]?.toInt()
dataClass
}
if (categoryId == DEFAULT_CATEGORY_ID) if (categoryId == DEFAULT_CATEGORY_ID)
return transaction { return transaction {
MangaTable.select { (MangaTable.inLibrary eq true) and (MangaTable.defaultCategory eq true) }.map { MangaTable
MangaTable.toDataClass(it) .slice(selectedColumns)
} .select { (MangaTable.inLibrary eq true) and (MangaTable.defaultCategory eq true) }
.map(transform)
} }
return transaction { return transaction {
CategoryMangaTable.innerJoin(MangaTable).select { CategoryMangaTable.category eq categoryId }.map { CategoryMangaTable.innerJoin(MangaTable)
MangaTable.toDataClass(it) .slice(selectedColumns)
} .select { CategoryMangaTable.category eq categoryId }
.map(transform)
} }
} }
@@ -9,9 +9,10 @@ package suwayomi.tachidesk.manga.impl
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga 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.chapter.ChapterRecognition
import org.jetbrains.exposed.dao.id.EntityID 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.and
import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insert
@@ -20,11 +21,14 @@ import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
import suwayomi.tachidesk.manga.impl.Manga.getManga import suwayomi.tachidesk.manga.impl.Manga.getManga
import suwayomi.tachidesk.manga.impl.Page.getPageName 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.getChapterDir
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.PaginatedList
import suwayomi.tachidesk.manga.model.dataclass.paginatedFrom
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
import suwayomi.tachidesk.manga.model.table.ChapterTable import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.MangaTable
@@ -40,7 +44,8 @@ object Chapter {
getSourceChapters(mangaId) getSourceChapters(mangaId)
} else { } else {
transaction { 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 { .map {
ChapterTable.toDataClass(it) ChapterTable.toDataClass(it)
} }
@@ -52,7 +57,7 @@ object Chapter {
private suspend fun getSourceChapters(mangaId: Int): List<ChapterDataClass> { private suspend fun getSourceChapters(mangaId: Int): List<ChapterDataClass> {
val manga = getManga(mangaId) val manga = getManga(mangaId)
val source = getHttpSource(manga.sourceId.toLong()) val source = getCatalogueSourceOrStub(manga.sourceId.toLong())
val sManga = SManga.create().apply { val sManga = SManga.create().apply {
title = manga.title title = manga.title
@@ -63,11 +68,12 @@ object Chapter {
// Recognize number for new chapters. // Recognize number for new chapters.
chapterList.forEach { chapterList.forEach {
source.prepareNewChapter(it, sManga) (source as? HttpSource)?.prepareNewChapter(it, sManga)
ChapterRecognition.parseChapterNumber(it, sManga) ChapterRecognition.parseChapterNumber(it, sManga)
} }
val chapterCount = chapterList.count() val chapterCount = chapterList.count()
var now = Instant.now().epochSecond
transaction { transaction {
chapterList.reversed().forEachIndexed { index, fetchedChapter -> chapterList.reversed().forEachIndexed { index, fetchedChapter ->
@@ -80,7 +86,8 @@ object Chapter {
it[chapter_number] = fetchedChapter.chapter_number it[chapter_number] = fetchedChapter.chapter_number
it[scanlator] = fetchedChapter.scanlator it[scanlator] = fetchedChapter.scanlator
it[chapterIndex] = index + 1 it[sourceOrder] = index + 1
it[fetchedAt] = now++
it[ChapterTable.manga] = mangaId it[ChapterTable.manga] = mangaId
} }
} else { } else {
@@ -90,7 +97,7 @@ object Chapter {
it[chapter_number] = fetchedChapter.chapter_number it[chapter_number] = fetchedChapter.chapter_number
it[scanlator] = fetchedChapter.scanlator it[scanlator] = fetchedChapter.scanlator
it[chapterIndex] = index + 1 it[sourceOrder] = index + 1
it[ChapterTable.manga] = mangaId it[ChapterTable.manga] = mangaId
} }
} }
@@ -101,14 +108,13 @@ object Chapter {
val dbChapterCount = transaction { ChapterTable.select { ChapterTable.manga eq mangaId }.count() } val dbChapterCount = transaction { ChapterTable.select { ChapterTable.manga eq mangaId }.count() }
if (dbChapterCount > chapterCount) { // we got some clean up due if (dbChapterCount > chapterCount) { // we got some clean up due
val dbChapterList = transaction { ChapterTable.select { ChapterTable.manga eq mangaId }.toList() } val dbChapterList = transaction { ChapterTable.select { ChapterTable.manga eq mangaId }.toList() }
val chapterUrls = chapterList.map { it.url }.toSet()
dbChapterList.forEach { dbChapterList.forEach { dbChapter ->
if (it[ChapterTable.chapterIndex] >= chapterList.size || if (!chapterUrls.contains(dbChapter[ChapterTable.url])) {
chapterList[it[ChapterTable.chapterIndex] - 1].url != it[ChapterTable.url]
) {
transaction { transaction {
PageTable.deleteWhere { PageTable.chapter eq it[ChapterTable.id] } PageTable.deleteWhere { PageTable.chapter eq dbChapter[ChapterTable.id] }
ChapterTable.deleteWhere { ChapterTable.id eq it[ChapterTable.id] } ChapterTable.deleteWhere { ChapterTable.id eq dbChapter[ChapterTable.id] }
} }
} }
} }
@@ -137,6 +143,7 @@ object Chapter {
dbChapter[ChapterTable.lastReadAt], dbChapter[ChapterTable.lastReadAt],
chapterCount - index, chapterCount - index,
dbChapter[ChapterTable.fetchedAt],
dbChapter[ChapterTable.isDownloaded], dbChapter[ChapterTable.isDownloaded],
dbChapter[ChapterTable.pageCount], dbChapter[ChapterTable.pageCount],
@@ -151,21 +158,24 @@ object Chapter {
suspend fun getChapter(chapterIndex: Int, mangaId: Int): ChapterDataClass { suspend fun getChapter(chapterIndex: Int, mangaId: Int): ChapterDataClass {
val chapterEntry = transaction { val chapterEntry = transaction {
ChapterTable.select { ChapterTable.select {
(ChapterTable.chapterIndex eq chapterIndex) and (ChapterTable.manga eq mangaId) (ChapterTable.sourceOrder eq chapterIndex) and (ChapterTable.manga eq mangaId)
}.first() }.first()
} }
val isReallyDownloaded = val isPartiallyDownloaded =
chapterEntry[ChapterTable.isDownloaded] && firstPageExists(mangaId, chapterEntry[ChapterTable.id].value) !(chapterEntry[ChapterTable.isDownloaded] && firstPageExists(mangaId, chapterEntry[ChapterTable.id].value))
return if (!isReallyDownloaded) {
return if (isPartiallyDownloaded) {
// chapter files may have been deleted
transaction { transaction {
ChapterTable.update({ (ChapterTable.chapterIndex eq chapterIndex) and (ChapterTable.manga eq mangaId) }) { ChapterTable.update({ (ChapterTable.sourceOrder eq chapterIndex) and (ChapterTable.manga eq mangaId) }) {
it[isDownloaded] = false it[isDownloaded] = false
} }
} }
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() } val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val source = getHttpSource(mangaEntry[MangaTable.sourceReference]) val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
val pageList = source.fetchPageList( val pageList = source.fetchPageList(
SChapter.create().apply { SChapter.create().apply {
@@ -203,7 +213,7 @@ object Chapter {
val pageCount = pageList.count() val pageCount = pageList.count()
transaction { 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 it[ChapterTable.pageCount] = pageCount
} }
} }
@@ -219,7 +229,8 @@ object Chapter {
chapterEntry[ChapterTable.lastPageRead], chapterEntry[ChapterTable.lastPageRead],
chapterEntry[ChapterTable.lastReadAt], chapterEntry[ChapterTable.lastReadAt],
chapterEntry[ChapterTable.chapterIndex], chapterEntry[ChapterTable.sourceOrder],
chapterEntry[ChapterTable.fetchedAt],
chapterEntry[ChapterTable.isDownloaded], chapterEntry[ChapterTable.isDownloaded],
pageCount, pageCount,
chapterCount.toInt(), chapterCount.toInt(),
@@ -235,7 +246,7 @@ object Chapter {
return ImageResponse.findFileNameStartingWith( return ImageResponse.findFileNameStartingWith(
chapterDir, chapterDir,
getPageName(0, chapterDir) getPageName(1)
) != null ) != null
} }
@@ -249,7 +260,7 @@ object Chapter {
) { ) {
transaction { transaction {
if (listOf(isRead, isBookmarked, lastPageRead).any { it != null }) { 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 { isRead?.also {
update[ChapterTable.isRead] = it update[ChapterTable.isRead] = it
} }
@@ -264,7 +275,7 @@ object Chapter {
} }
markPrevRead?.let { 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 it[ChapterTable.isRead] = markPrevRead
} }
} }
@@ -281,7 +292,7 @@ object Chapter {
fun modifyChapterMeta(mangaId: Int, chapterIndex: Int, key: String, value: String) { fun modifyChapterMeta(mangaId: Int, chapterIndex: Int, key: String, value: String) {
transaction { transaction {
val chapterId = val chapterId =
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) } ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }
.first()[ChapterTable.id].value .first()[ChapterTable.id].value
val meta = val meta =
transaction { ChapterMetaTable.select { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) } }.firstOrNull() transaction { ChapterMetaTable.select { (ChapterMetaTable.ref eq chapterId) and (ChapterMetaTable.key eq key) } }.firstOrNull()
@@ -302,16 +313,32 @@ object Chapter {
fun deleteChapter(mangaId: Int, chapterIndex: Int) { fun deleteChapter(mangaId: Int, chapterIndex: Int) {
transaction { transaction {
val chapterId = val chapterId =
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) } ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }
.first()[ChapterTable.id].value .first()[ChapterTable.id].value
val chapterDir = getChapterDir(mangaId, chapterId) val chapterDir = getChapterDir(mangaId, chapterId)
File(chapterDir).deleteRecursively() File(chapterDir).deleteRecursively()
ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) }) { ChapterTable.update({ (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }) {
it[isDownloaded] = false it[isDownloaded] = false
} }
} }
} }
fun getRecentChapters(pageNum: Int): PaginatedList<MangaChapterDataClass> {
return paginatedFrom(pageNum) {
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.CategoryMangaTable
import suwayomi.tachidesk.manga.model.table.CategoryTable import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.MangaTable
import java.time.Instant
object Library { object Library {
suspend fun addMangaToLibrary(mangaId: Int) { suspend fun addMangaToLibrary(mangaId: Int) {
@@ -25,8 +26,9 @@ object Library {
val defaultCategories = CategoryTable.select { CategoryTable.isDefault eq true }.toList() val defaultCategories = CategoryTable.select { CategoryTable.isDefault eq true }.toList()
MangaTable.update({ MangaTable.id eq manga.id }) { MangaTable.update({ MangaTable.id eq manga.id }) {
it[MangaTable.inLibrary] = true it[inLibrary] = true
it[MangaTable.defaultCategory] = defaultCategories.isEmpty() it[inLibraryAt] = Instant.now().epochSecond
it[defaultCategory] = defaultCategories.isEmpty()
} }
defaultCategories.forEach { category -> defaultCategories.forEach { category ->
@@ -8,7 +8,9 @@ package suwayomi.tachidesk.manga.impl
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.local.LocalSource
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
@@ -19,11 +21,12 @@ import org.kodein.di.conf.global
import org.kodein.di.instance import org.kodein.di.instance
import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl import suwayomi.tachidesk.manga.impl.MangaList.proxyThumbnailUrl
import suwayomi.tachidesk.manga.impl.Source.getSource 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.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.network.await import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.clearCachedImage
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import suwayomi.tachidesk.manga.impl.util.updateMangaDownloadDir import suwayomi.tachidesk.manga.impl.util.updateMangaDownloadDir
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.toGenreList import suwayomi.tachidesk.manga.model.dataclass.toGenreList
@@ -31,6 +34,8 @@ import suwayomi.tachidesk.manga.model.table.MangaMetaTable
import suwayomi.tachidesk.manga.model.table.MangaStatus import suwayomi.tachidesk.manga.model.table.MangaStatus
import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.server.ApplicationDirs import suwayomi.tachidesk.server.ApplicationDirs
import java.io.File
import java.io.IOException
import java.io.InputStream import java.io.InputStream
object Manga { object Manga {
@@ -61,43 +66,43 @@ object Manga {
mangaEntry[MangaTable.genre].toGenreList(), mangaEntry[MangaTable.genre].toGenreList(),
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name, MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
mangaEntry[MangaTable.inLibrary], mangaEntry[MangaTable.inLibrary],
mangaEntry[MangaTable.inLibraryAt],
getSource(mangaEntry[MangaTable.sourceReference]), getSource(mangaEntry[MangaTable.sourceReference]),
getMangaMetaMap(mangaId), getMangaMetaMap(mangaId),
mangaEntry[MangaTable.realUrl], mangaEntry[MangaTable.realUrl],
false false
) )
} else { // initialize manga } else { // initialize manga
val source = getHttpSource(mangaEntry[MangaTable.sourceReference]) val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
val sManga = SManga.create().apply { val sManga = SManga.create().apply {
url = mangaEntry[MangaTable.url] url = mangaEntry[MangaTable.url]
title = mangaEntry[MangaTable.title] title = mangaEntry[MangaTable.title]
} }
val fetchedManga = source.fetchMangaDetails(sManga).awaitSingle() val networkManga = source.fetchMangaDetails(sManga).awaitSingle()
sManga.copyFrom(networkManga)
transaction { transaction {
MangaTable.update({ MangaTable.id eq mangaId }) { MangaTable.update({ MangaTable.id eq mangaId }) {
if (fetchedManga.title != mangaEntry[MangaTable.title]) { if (sManga.title != mangaEntry[MangaTable.title]) {
val canUpdateTitle = updateMangaDownloadDir(mangaId, fetchedManga.title) val canUpdateTitle = updateMangaDownloadDir(mangaId, sManga.title)
if (canUpdateTitle) if (canUpdateTitle)
it[MangaTable.title] = fetchedManga.title it[MangaTable.title] = sManga.title
} }
it[MangaTable.initialized] = true it[MangaTable.initialized] = true
it[MangaTable.artist] = fetchedManga.artist it[MangaTable.artist] = sManga.artist
it[MangaTable.author] = fetchedManga.author it[MangaTable.author] = sManga.author
it[MangaTable.description] = truncate(fetchedManga.description, 4096) it[MangaTable.description] = truncate(sManga.description, 4096)
it[MangaTable.genre] = fetchedManga.genre it[MangaTable.genre] = sManga.genre
it[MangaTable.status] = fetchedManga.status it[MangaTable.status] = sManga.status
if (fetchedManga.thumbnail_url != null && fetchedManga.thumbnail_url.orEmpty().isNotEmpty()) if (sManga.thumbnail_url != null && sManga.thumbnail_url.orEmpty().isNotEmpty())
it[MangaTable.thumbnail_url] = fetchedManga.thumbnail_url it[MangaTable.thumbnail_url] = sManga.thumbnail_url
it[MangaTable.realUrl] = try { it[MangaTable.realUrl] = runCatching {
source.mangaDetailsRequest(sManga).url.toString() (source as? HttpSource)?.mangaDetailsRequest(sManga)?.url?.toString()
} catch (e: Exception) { }.getOrNull()
null
}
} }
} }
@@ -115,12 +120,13 @@ object Manga {
true, true,
fetchedManga.artist, sManga.artist,
fetchedManga.author, sManga.author,
fetchedManga.description, sManga.description,
fetchedManga.genre.toGenreList(), sManga.genre.toGenreList(),
MangaStatus.valueOf(fetchedManga.status).name, MangaStatus.valueOf(sManga.status).name,
mangaEntry[MangaTable.inLibrary], mangaEntry[MangaTable.inLibrary],
mangaEntry[MangaTable.inLibraryAt],
getSource(mangaEntry[MangaTable.sourceReference]), getSource(mangaEntry[MangaTable.sourceReference]),
getMangaMetaMap(mangaId), getMangaMetaMap(mangaId),
mangaEntry[MangaTable.realUrl], mangaEntry[MangaTable.realUrl],
@@ -138,7 +144,7 @@ object Manga {
fun modifyMangaMeta(mangaId: Int, key: String, value: String) { fun modifyMangaMeta(mangaId: Int, key: String, value: String) {
transaction { transaction {
val manga = MangaMetaTable.select { (MangaTable.id eq mangaId) } val manga = MangaTable.select { MangaTable.id eq mangaId }
.first()[MangaTable.id] .first()[MangaTable.id]
val meta = val meta =
transaction { MangaMetaTable.select { (MangaMetaTable.ref eq manga) and (MangaMetaTable.key eq key) } }.firstOrNull() transaction { MangaMetaTable.select { (MangaMetaTable.ref eq manga) and (MangaMetaTable.key eq key) } }.firstOrNull()
@@ -158,33 +164,47 @@ object Manga {
private val applicationDirs by DI.global.instance<ApplicationDirs>() private val applicationDirs by DI.global.instance<ApplicationDirs>()
suspend fun getMangaThumbnail(mangaId: Int, useCache: Boolean): Pair<InputStream, String> { suspend fun getMangaThumbnail(mangaId: Int, useCache: Boolean): Pair<InputStream, String> {
val saveDir = applicationDirs.mangaThumbnailsRoot val saveDir = applicationDirs.thumbnailsRoot
val fileName = mangaId.toString() val fileName = mangaId.toString()
return getImageResponse(saveDir, fileName, useCache) { val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() } val sourceId = mangaEntry[MangaTable.sourceReference]
val sourceId = mangaEntry[MangaTable.sourceReference] return when (val source = getCatalogueSourceOrStub(sourceId)) {
val source = getHttpSource(sourceId) is HttpSource -> getImageResponse(saveDir, fileName, useCache) {
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()
}
val thumbnailUrl = mangaEntry[MangaTable.thumbnail_url] source.client.newCall(
?: if (!mangaEntry[MangaTable.initialized]) { GET(thumbnailUrl, source.headers)
// initialize then try again ).await()
getManga(mangaId) }
transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }[MangaTable.thumbnail_url]!! is LocalSource -> {
} else { val imageFile = mangaEntry[MangaTable.thumbnail_url]?.let {
// source provides no thumbnail url for this manga val file = File(it)
throw NullPointerException() if (file.exists()) {
} file
} else {
source.client.newCall( null
GET(thumbnailUrl, source.headers) }
).await() } ?: throw IOException("Thumbnail does not exist")
val contentType = ImageUtil.findImageType { imageFile.inputStream() }?.mime
?: "image/jpeg"
imageFile.inputStream() to contentType
}
else -> throw IllegalArgumentException("Unknown source")
} }
} }
private fun clearMangaThumbnail(mangaId: Int) { private fun clearMangaThumbnail(mangaId: Int) {
val saveDir = applicationDirs.mangaThumbnailsRoot val saveDir = applicationDirs.thumbnailsRoot
val fileName = mangaId.toString() val fileName = mangaId.toString()
clearCachedImage(saveDir, fileName) clearCachedImage(saveDir, fileName)
@@ -13,8 +13,8 @@ import org.jetbrains.exposed.sql.insertAndGetId
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.manga.impl.Manga.getMangaMetaMap import suwayomi.tachidesk.manga.impl.Manga.getMangaMetaMap
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass
import suwayomi.tachidesk.manga.model.dataclass.toGenreList import suwayomi.tachidesk.manga.model.dataclass.toGenreList
@@ -27,14 +27,15 @@ object MangaList {
} }
suspend fun getMangaList(sourceId: Long, pageNum: Int = 1, popular: Boolean): PagedMangaListDataClass { suspend fun getMangaList(sourceId: Long, pageNum: Int = 1, popular: Boolean): PagedMangaListDataClass {
val source = getHttpSource(sourceId) val source = getCatalogueSourceOrStub(sourceId)
val mangasPage = if (popular) { val mangasPage = if (popular) {
source.fetchPopularManga(pageNum).awaitSingle() source.fetchPopularManga(pageNum).awaitSingle()
} else { } else {
if (source.supportsLatest) if (source.supportsLatest) {
source.fetchLatestUpdates(pageNum).awaitSingle() source.fetchLatestUpdates(pageNum).awaitSingle()
else } else {
throw Exception("Source $source doesn't support latest") throw Exception("Source $source doesn't support latest")
}
} }
return mangasPage.processEntries(sourceId) return mangasPage.processEntries(sourceId)
} }
@@ -81,6 +82,7 @@ object MangaList {
manga.genre.toGenreList(), manga.genre.toGenreList(),
MangaStatus.valueOf(manga.status).name, MangaStatus.valueOf(manga.status).name,
false, // It's a new manga entry false, // It's a new manga entry
0,
meta = getMangaMetaMap(mangaId), meta = getMangaMetaMap(mangaId),
realUrl = mangaEntry[MangaTable.realUrl], realUrl = mangaEntry[MangaTable.realUrl],
freshData = true freshData = true
@@ -103,6 +105,7 @@ object MangaList {
mangaEntry[MangaTable.genre].toGenreList(), mangaEntry[MangaTable.genre].toGenreList(),
MangaStatus.valueOf(mangaEntry[MangaTable.status]).name, MangaStatus.valueOf(mangaEntry[MangaTable.status]).name,
mangaEntry[MangaTable.inLibrary], mangaEntry[MangaTable.inLibrary],
mangaEntry[MangaTable.inLibraryAt],
meta = getMangaMetaMap(mangaId), meta = getMangaMetaMap(mangaId),
realUrl = mangaEntry[MangaTable.realUrl], realUrl = mangaEntry[MangaTable.realUrl],
freshData = false freshData = false
@@ -14,18 +14,14 @@ import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
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.getChapterDir
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
import suwayomi.tachidesk.manga.model.table.ChapterTable import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.MangaTable
import suwayomi.tachidesk.manga.model.table.PageTable import suwayomi.tachidesk.manga.model.table.PageTable
import suwayomi.tachidesk.server.ApplicationDirs
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
@@ -43,10 +39,10 @@ object Page {
suspend fun getPageImage(mangaId: Int, chapterIndex: Int, index: Int, useCache: Boolean = true): 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 mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
val source = getHttpSource(mangaEntry[MangaTable.sourceReference]) val source = getCatalogueSourceOrStub(mangaEntry[MangaTable.sourceReference])
val chapterEntry = transaction { val chapterEntry = transaction {
ChapterTable.select { ChapterTable.select {
(ChapterTable.chapterIndex eq chapterIndex) and (ChapterTable.manga eq mangaId) (ChapterTable.sourceOrder eq chapterIndex) and (ChapterTable.manga eq mangaId)
}.first() }.first()
} }
val chapterId = chapterEntry[ChapterTable.id].value val chapterId = chapterEntry[ChapterTable.id].value
@@ -61,19 +57,20 @@ object Page {
) )
// we treat Local source differently // we treat Local source differently
if (mangaEntry[MangaTable.sourceReference] == LocalSource.ID) { if (source.id == LocalSource.ID) {
// is of archive format // is of archive format
if (LocalSource.pageCache.containsKey(chapterEntry[ChapterTable.url])) { if (LocalSource.pageCache.containsKey(chapterEntry[ChapterTable.url])) {
val pageStream = LocalSource.pageCache[chapterEntry[ChapterTable.url]]!![index]() val pageStream = LocalSource.pageCache[chapterEntry[ChapterTable.url]]!![index]
return pageStream to "image/jpeg" return pageStream() to (ImageUtil.findImageType { pageStream() }?.mime ?: "image/jpeg")
} }
// is of directory format // is of directory format
return ImageResponse.getNoCacheImageResponse { val imageFile = File(tachiyomiPage.imageUrl!!)
source.fetchImage(tachiyomiPage).awaitSingle() return imageFile.inputStream() to (ImageUtil.findImageType { imageFile.inputStream() }?.mime ?: "image/jpeg")
}
} }
source as HttpSource
if (pageEntry[PageTable.imageUrl] == null) { if (pageEntry[PageTable.imageUrl] == null) {
val trueImageUrl = getTrueImageUrl(tachiyomiPage, source) val trueImageUrl = getTrueImageUrl(tachiyomiPage, source)
transaction { transaction {
@@ -85,26 +82,15 @@ object Page {
val chapterDir = getChapterDir(mangaId, chapterId) val chapterDir = getChapterDir(mangaId, chapterId)
File(chapterDir).mkdirs() File(chapterDir).mkdirs()
val fileName = getPageName(index, chapterDir) // e.g. 001 val fileName = getPageName(index)
return getImageResponse(chapterDir, fileName, useCache) { return getImageResponse(chapterDir, fileName, useCache) {
source.fetchImage(tachiyomiPage).awaitSingle() source.fetchImage(tachiyomiPage).awaitSingle()
} }
} }
// TODO(v0.6.0) : zero based pages are deprecated /** converts 0 to "001" */
fun getPageName(index: Int, chapterDir: String): String { fun getPageName(index: Int): String {
val zeroBasedPageExists = ImageResponse.findFileNameStartingWith( return String.format("%03d", index + 1)
chapterDir,
formatPageName(0)
) != null
if (zeroBasedPageExists) return formatPageName(index)
return formatPageName(index + 1)
} }
private fun formatPageName(index: Int) = String.format("%03d", index)
private val applicationDirs by DI.global.instance<ApplicationDirs>()
} }
@@ -7,99 +7,117 @@ package suwayomi.tachidesk.manga.impl
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import io.javalin.plugin.json.JsonMapper
import org.kodein.di.DI
import org.kodein.di.conf.global
import org.kodein.di.instance
import suwayomi.tachidesk.manga.impl.MangaList.processEntries import suwayomi.tachidesk.manga.impl.MangaList.processEntries
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource
import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass
object Search { object Search {
suspend fun sourceSearch(sourceId: Long, searchTerm: String, pageNum: Int): PagedMangaListDataClass { suspend fun sourceSearch(sourceId: Long, searchTerm: String, pageNum: Int): PagedMangaListDataClass {
val source = getHttpSource(sourceId) val source = getCatalogueSourceOrStub(sourceId)
val searchManga = source.fetchSearchManga(pageNum, searchTerm, getFilterListOf(sourceId)).awaitSingle() val searchManga = source.fetchSearchManga(pageNum, searchTerm, getFilterListOf(source)).awaitSingle()
return searchManga.processEntries(sourceId) return searchManga.processEntries(sourceId)
} }
private val filterListCache = mutableMapOf<Long, FilterList>() private val filterListCache = mutableMapOf<Long, FilterList>()
private fun getFilterListOf(sourceId: Long, reset: Boolean = false): FilterList { private fun getFilterListOf(source: CatalogueSource, reset: Boolean = false): FilterList {
if (reset || !filterListCache.containsKey(sourceId)) { if (reset || !filterListCache.containsKey(source.id)) {
filterListCache[sourceId] = getHttpSource(sourceId).getFilterList() filterListCache[source.id] = source.getFilterList()
} }
return filterListCache[sourceId]!! return filterListCache[source.id]!!
} }
fun getInitialFilterList(sourceId: Long, reset: Boolean): List<FilterObject> { fun getFilterList(sourceId: Long, reset: Boolean): List<FilterObject> {
return getFilterListOf(sourceId, reset).list.map { val source = getCatalogueSourceOrStub(sourceId)
return getFilterListOf(source, reset).list.map {
FilterObject( FilterObject(
when (it) { when (it) {
is Filter.Header -> "Header" is Filter.Header -> "Header"
is Filter.Separator -> "Separator" is Filter.Separator -> "Separator"
is Filter.Select<*> -> "Select"
is Filter.Text -> "Text"
is Filter.CheckBox -> "CheckBox" is Filter.CheckBox -> "CheckBox"
is Filter.TriState -> "TriState" is Filter.TriState -> "TriState"
is Filter.Text -> "Text"
is Filter.Select<*> -> "Select"
is Filter.Group<*> -> "Group" is Filter.Group<*> -> "Group"
is Filter.Sort -> "Sort" is Filter.Sort -> "Sort"
else -> throw RuntimeException("sealed class Cannot have more Subtypes!")
}, },
// when (it) { when (it) {
// is Filter.Select<*> -> it.getValuesType() is Filter.Group<*> -> {
// else -> null SerializableGroup(
// }, it.name,
it it.state.map { item ->
when (item) {
is Filter.CheckBox -> FilterObject("CheckBox", item)
is Filter.TriState -> FilterObject("TriState", item)
is Filter.Text -> FilterObject("Text", item)
is Filter.Select<*> -> FilterObject("Select", item)
else -> throw RuntimeException("Illegal Group item type!")
}
}
)
}
else -> it
}
) )
} }
} }
// private fun Filter.Select<*>.getValuesType(): String = values::class.java.componentType!!.simpleName private fun Filter.Select<*>.getValuesType(): String = values::class.java.componentType!!.simpleName
class SerializableGroup(name: String, state: List<FilterObject>) : Filter<List<FilterObject>>(name, state)
data class FilterObject( data class FilterObject(
val type: String, val type: String,
val filter: Filter<*> val filter: Filter<*>,
)
fun setFilter(sourceId: Long, change: FilterChange) {
val source = getCatalogueSourceOrStub(sourceId)
val filterList = getFilterListOf(source, false)
when (val filter = filterList[change.position]) {
is Filter.Header -> {
// NOOP
}
is Filter.Separator -> {
// NOOP
}
is Filter.Select<*> -> filter.state = change.state.toInt()
is Filter.Text -> filter.state = change.state
is Filter.CheckBox -> filter.state = change.state.toBooleanStrict()
is Filter.TriState -> filter.state = change.state.toInt()
is Filter.Group<*> -> {
val groupChange = jsonMapper.fromJsonString(change.state, FilterChange::class.java)
when (val groupFilter = filter.state[groupChange.position]) {
is Filter.CheckBox -> groupFilter.state = groupChange.state.toBooleanStrict()
is Filter.TriState -> groupFilter.state = groupChange.state.toInt()
is Filter.Text -> groupFilter.state = groupChange.state
is Filter.Select<*> -> groupFilter.state = groupChange.state.toInt()
}
}
is Filter.Sort -> filter.state = jsonMapper.fromJsonString(change.state, Filter.Sort.Selection::class.java)
}
}
private val jsonMapper by DI.global.instance<JsonMapper>()
data class FilterChange(
val position: Int,
val state: String
) )
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
fun sourceGlobalSearch(searchTerm: String) { fun sourceGlobalSearch(searchTerm: String) {
// TODO // TODO
} }
/**
* Note: Exhentai had a filter serializer (now in SY) that we might be able to steal
*/
// private fun FilterList.toFilterWrapper(): List<FilterWrapper> {
// return mapNotNull { filter ->
// when (filter) {
// is Filter.Header -> FilterWrapper("Header",filter)
// is Filter.Separator -> FilterWrapper("Separator",filter)
// is Filter.CheckBox -> FilterWrapper("CheckBox",filter)
// is Filter.TriState -> FilterWrapper("TriState",filter)
// is Filter.Text -> FilterWrapper("Text",filter)
// is Filter.Select<*> -> FilterWrapper("Select",filter)
// is Filter.Group<*> -> {
// val group = GroupItem(filter)
// val subItems = filter.state.mapNotNull {
// when (it) {
// is Filter.CheckBox -> FilterWrapper("CheckBox",filter)
// is Filter.TriState -> FilterWrapper("TriState",filter)
// is Filter.Text -> FilterWrapper("Text",filter)
// is Filter.Select<*> -> FilterWrapper("Select",filter)
// else -> null
// } as? ISectionable<*, *>
// }
// subItems.forEach { it.header = group }
// group.subItems = subItems
// group
// }
// is Filter.Sort -> {
// val group = SortGroup(filter)
// val subItems = filter.values.map {
// SortItem(it, group)
// }
// group.subItems = subItems
// group
// }
// }
// }
// }
} }
@@ -12,7 +12,7 @@ import android.content.Context
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.getPreferenceKey import eu.kanade.tachiyomi.source.getPreferenceKey
import eu.kanade.tachiyomi.source.local.LocalSource import io.javalin.plugin.json.JsonMapper
import mu.KotlinLogging import mu.KotlinLogging
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
@@ -21,8 +21,9 @@ import org.kodein.di.DI
import org.kodein.di.conf.global import org.kodein.di.conf.global
import org.kodein.di.instance import org.kodein.di.instance
import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.getHttpSource import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSource
import suwayomi.tachidesk.manga.impl.util.GetHttpSource.invalidateSourceCache import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.unregisterCatalogueSource
import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass
import suwayomi.tachidesk.manga.model.table.ExtensionTable import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.manga.model.table.SourceTable
@@ -36,7 +37,7 @@ object Source {
fun getSourceList(): List<SourceDataClass> { fun getSourceList(): List<SourceDataClass> {
return transaction { return transaction {
SourceTable.selectAll().map { SourceTable.selectAll().map {
val httpSource = getHttpSource(it[SourceTable.id].value) val catalogueSource = getCatalogueSourceOrStub(it[SourceTable.id].value)
val sourceExtension = ExtensionTable.select { ExtensionTable.id eq it[SourceTable.extension] }.first() val sourceExtension = ExtensionTable.select { ExtensionTable.id eq it[SourceTable.extension] }.first()
SourceDataClass( SourceDataClass(
@@ -44,10 +45,10 @@ object Source {
it[SourceTable.name], it[SourceTable.name],
it[SourceTable.lang], it[SourceTable.lang],
getExtensionIconUrl(sourceExtension[ExtensionTable.apkName]), getExtensionIconUrl(sourceExtension[ExtensionTable.apkName]),
httpSource.supportsLatest, catalogueSource.supportsLatest,
httpSource is ConfigurableSource, catalogueSource is ConfigurableSource,
it[SourceTable.isNsfw], it[SourceTable.isNsfw],
httpSource.toString(), catalogueSource.toString(),
) )
} }
} }
@@ -55,13 +56,8 @@ object Source {
fun getSource(sourceId: Long): SourceDataClass { // all the data extracted fresh form the source instance fun getSource(sourceId: Long): SourceDataClass { // all the data extracted fresh form the source instance
return transaction { return transaction {
if (sourceId == LocalSource.ID) {
// initialize local source
getHttpSource(sourceId)
}
val source = SourceTable.select { SourceTable.id eq sourceId }.firstOrNull() val source = SourceTable.select { SourceTable.id eq sourceId }.firstOrNull()
val httpSource = source?.let { getHttpSource(sourceId) } val catalogueSource = source?.let { getCatalogueSource(sourceId) }
val extension = source?.let { val extension = source?.let {
ExtensionTable.select { ExtensionTable.id eq source[SourceTable.extension] }.first() ExtensionTable.select { ExtensionTable.id eq source[SourceTable.extension] }.first()
} }
@@ -75,10 +71,10 @@ object Source {
extension!![ExtensionTable.apkName] extension!![ExtensionTable.apkName]
) )
}, },
httpSource?.supportsLatest, catalogueSource?.supportsLatest,
httpSource?.let { it is ConfigurableSource }, catalogueSource?.let { it is ConfigurableSource },
source?.get(SourceTable.isNsfw), source?.get(SourceTable.isNsfw),
httpSource?.toString() catalogueSource?.toString()
) )
} }
} }
@@ -86,11 +82,12 @@ object Source {
private val context by DI.global.instance<CustomContext>() private val context by DI.global.instance<CustomContext>()
/** /**
* (2021-08) Clients should support these types for extensions to work properly * (2021-11) Clients should support these types for extensions to work properly
* - EditTextPreference * - EditTextPreference
* - SwitchPreferenceCompat * - SwitchPreferenceCompat
* - ListPreference * - ListPreference
* - CheckBoxPreference * - CheckBoxPreference
* - MultiSelectListPreference
*/ */
data class PreferenceObject( data class PreferenceObject(
val type: String, val type: String,
@@ -103,7 +100,7 @@ object Source {
* Gets a source's PreferenceScreen, puts the result into [preferenceScreenMap] * Gets a source's PreferenceScreen, puts the result into [preferenceScreenMap]
*/ */
fun getSourcePreferences(sourceId: Long): List<PreferenceObject> { fun getSourcePreferences(sourceId: Long): List<PreferenceObject> {
val source = getHttpSource(sourceId) val source = getCatalogueSourceOrStub(sourceId)
if (source is ConfigurableSource) { if (source is ConfigurableSource) {
val sourceShardPreferences = val sourceShardPreferences =
@@ -128,20 +125,25 @@ object Source {
val value: String val value: String
) )
private val jsonMapper by DI.global.instance<JsonMapper>()
@Suppress("IMPLICIT_CAST_TO_ANY", "UNCHECKED_CAST")
fun setSourcePreference(sourceId: Long, change: SourcePreferenceChange) { fun setSourcePreference(sourceId: Long, change: SourcePreferenceChange) {
val screen = preferenceScreenMap[sourceId]!! val screen = preferenceScreenMap[sourceId]!!
val pref = screen.preferences[change.position] val pref = screen.preferences[change.position]
println(jsonMapper::class.java.name)
val newValue = when (pref.defaultValueType) { val newValue = when (pref.defaultValueType) {
"String" -> change.value "String" -> change.value
"Boolean" -> change.value.toBoolean() "Boolean" -> change.value.toBoolean()
"Set<String>" -> jsonMapper.fromJsonString(change.value, List::class.java as Class<List<String>>).toSet()
else -> throw RuntimeException("Unsupported type conversion") else -> throw RuntimeException("Unsupported type conversion")
} }
pref.saveNewValue(newValue) pref.saveNewValue(newValue)
pref.callChangeListener(newValue) pref.callChangeListener(newValue)
// must reload the source cache because a preference was changed // must reload the source because a preference was changed
invalidateSourceCache(sourceId) unregisterCatalogueSource(sourceId)
} }
} }
@@ -32,6 +32,7 @@ import suwayomi.tachidesk.manga.model.table.SourceTable
import suwayomi.tachidesk.manga.model.table.toDataClass import suwayomi.tachidesk.manga.model.table.toDataClass
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import java.util.concurrent.TimeUnit
object ProtoBackupExport : ProtoBackupBase() { object ProtoBackupExport : ProtoBackupBase() {
suspend fun createBackup(flags: BackupFlags): InputStream { suspend fun createBackup(flags: BackupFlags): InputStream {
@@ -68,7 +69,7 @@ object ProtoBackupExport : ProtoBackupBase() {
mangaRow[MangaTable.genre]?.split(", ") ?: emptyList(), mangaRow[MangaTable.genre]?.split(", ") ?: emptyList(),
MangaStatus.valueOf(mangaRow[MangaTable.status]).value, MangaStatus.valueOf(mangaRow[MangaTable.status]).value,
mangaRow[MangaTable.thumbnail_url], mangaRow[MangaTable.thumbnail_url],
0, // not supported in Tachidesk TimeUnit.SECONDS.toMillis(mangaRow[MangaTable.inLibraryAt]),
0, // not supported in Tachidesk 0, // not supported in Tachidesk
) )
@@ -84,10 +85,10 @@ object ProtoBackupExport : ProtoBackupBase() {
it.read, it.read,
it.bookmarked, it.bookmarked,
it.lastPageRead, it.lastPageRead,
0, // not supported in Tachidesk TimeUnit.SECONDS.toMillis(it.fetchedAt),
it.uploadDate, it.uploadDate,
it.chapterNumber, it.chapterNumber,
it.index, chapters.size - it.index,
) )
} }
} }
@@ -34,6 +34,7 @@ import suwayomi.tachidesk.manga.model.table.MangaTable
import java.io.InputStream import java.io.InputStream
import java.lang.Integer.max import java.lang.Integer.max
import java.util.Date import java.util.Date
import java.util.concurrent.TimeUnit
object ProtoBackupImport : ProtoBackupBase() { object ProtoBackupImport : ProtoBackupBase() {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@@ -148,6 +149,8 @@ object ProtoBackupImport : ProtoBackupBase() {
it[initialized] = manga.description != null it[initialized] = manga.description != null
it[inLibrary] = manga.favorite it[inLibrary] = manga.favorite
it[inLibraryAt] = TimeUnit.MILLISECONDS.toSeconds(manga.date_added)
}.value }.value
// insert chapter data // insert chapter data
@@ -160,12 +163,14 @@ object ProtoBackupImport : ProtoBackupBase() {
it[chapter_number] = chapter.chapter_number it[chapter_number] = chapter.chapter_number
it[scanlator] = chapter.scanlator it[scanlator] = chapter.scanlator
it[chapterIndex] = chaptersLength - chapter.source_order it[sourceOrder] = chaptersLength - chapter.source_order
it[ChapterTable.manga] = mangaId it[ChapterTable.manga] = mangaId
it[isRead] = chapter.read it[isRead] = chapter.read
it[lastPageRead] = chapter.last_page_read it[lastPageRead] = chapter.last_page_read
it[isBookmarked] = chapter.bookmark it[isBookmarked] = chapter.bookmark
it[fetchedAt] = TimeUnit.MILLISECONDS.toSeconds(chapter.date_fetch)
} }
} }
@@ -190,6 +195,8 @@ object ProtoBackupImport : ProtoBackupBase() {
it[initialized] = dbManga[initialized] || manga.description != null it[initialized] = dbManga[initialized] || manga.description != null
it[inLibrary] = manga.favorite || dbManga[inLibrary] it[inLibrary] = manga.favorite || dbManga[inLibrary]
it[inLibraryAt] = TimeUnit.MILLISECONDS.toSeconds(manga.date_added)
} }
// merge chapter data // merge chapter data
@@ -207,7 +214,7 @@ object ProtoBackupImport : ProtoBackupBase() {
it[chapter_number] = chapter.chapter_number it[chapter_number] = chapter.chapter_number
it[scanlator] = chapter.scanlator it[scanlator] = chapter.scanlator
it[chapterIndex] = chaptersLength - chapter.source_order it[sourceOrder] = chaptersLength - chapter.source_order
it[ChapterTable.manga] = mangaId it[ChapterTable.manga] = mangaId
it[isRead] = chapter.read it[isRead] = chapter.read
@@ -12,6 +12,7 @@ import io.javalin.websocket.WsMessageContext
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.select import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.manga.impl.Manga.getManga
import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter import suwayomi.tachidesk.manga.impl.download.model.DownloadChapter
import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Downloading import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Downloading
import suwayomi.tachidesk.manga.impl.download.model.DownloadStatus import suwayomi.tachidesk.manga.impl.download.model.DownloadStatus
@@ -69,7 +70,7 @@ object DownloadManager {
) )
} }
fun enqueue(chapterIndex: Int, mangaId: Int) { suspend fun enqueue(chapterIndex: Int, mangaId: Int) {
if (downloadQueue.none { it.mangaId == mangaId && it.chapterIndex == chapterIndex }) { if (downloadQueue.none { it.mangaId == mangaId && it.chapterIndex == chapterIndex }) {
downloadQueue.add( downloadQueue.add(
DownloadChapter( DownloadChapter(
@@ -77,10 +78,11 @@ object DownloadManager {
mangaId, mangaId,
chapter = ChapterTable.toDataClass( chapter = ChapterTable.toDataClass(
transaction { transaction {
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.chapterIndex eq chapterIndex) } ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }
.first() .first()
} }
) ),
manga = getManga(mangaId)
) )
) )
start() start()
@@ -50,7 +50,7 @@ class Downloader(private val downloadQueue: CopyOnWriteArrayList<DownloadChapter
download.chapter = runBlocking { getChapter(download.chapterIndex, download.mangaId) } download.chapter = runBlocking { getChapter(download.chapterIndex, download.mangaId) }
step() step()
val pageCount = download.chapter!!.pageCount val pageCount = download.chapter.pageCount
for (pageNum in 0 until pageCount) { for (pageNum in 0 until pageCount) {
runBlocking { getPageImage(download.mangaId, download.chapterIndex, pageNum) } runBlocking { getPageImage(download.mangaId, download.chapterIndex, pageNum) }
// TODO: retry on error with 2,4,8 seconds of wait // TODO: retry on error with 2,4,8 seconds of wait
@@ -61,7 +61,7 @@ class Downloader(private val downloadQueue: CopyOnWriteArrayList<DownloadChapter
} }
download.state = Finished download.state = Finished
transaction { 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 it[isDownloaded] = true
} }
} }
@@ -9,12 +9,14 @@ package suwayomi.tachidesk.manga.impl.download.model
import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Queued import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Queued
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
class DownloadChapter( class DownloadChapter(
val chapterIndex: Int, val chapterIndex: Int,
val mangaId: Int, val mangaId: Int,
var chapter: ChapterDataClass,
var manga: MangaDataClass,
var state: DownloadState = Queued, var state: DownloadState = Queued,
var progress: Float = 0f, var progress: Float = 0f,
var tries: Int = 0, var tries: Int = 0,
var chapter: ChapterDataClass? = null,
) )
@@ -28,7 +28,6 @@ import org.kodein.di.conf.global
import org.kodein.di.instance import org.kodein.di.instance
import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.extensionTableAsDataClass import suwayomi.tachidesk.manga.impl.extension.ExtensionsList.extensionTableAsDataClass
import suwayomi.tachidesk.manga.impl.extension.github.ExtensionGithubApi 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
import suwayomi.tachidesk.manga.impl.util.PackageTools.EXTENSION_FEATURE 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_MAX
@@ -39,6 +38,7 @@ import suwayomi.tachidesk.manga.impl.util.PackageTools.dex2jar
import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo import suwayomi.tachidesk.manga.impl.util.PackageTools.getPackageInfo
import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources import suwayomi.tachidesk.manga.impl.util.PackageTools.loadExtensionSources
import suwayomi.tachidesk.manga.impl.util.network.await import suwayomi.tachidesk.manga.impl.util.network.await
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse.getImageResponse
import suwayomi.tachidesk.manga.model.table.ExtensionTable import suwayomi.tachidesk.manga.model.table.ExtensionTable
import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.manga.model.table.SourceTable
@@ -51,9 +51,6 @@ object Extension {
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
private val applicationDirs by DI.global.instance<ApplicationDirs>() private val applicationDirs by DI.global.instance<ApplicationDirs>()
private fun Any.isNsfw(): Boolean =
this::class.annotations.any { it.toString() == "@eu.kanade.tachiyomi.annotations.Nsfw()" }
suspend fun installExtension(pkgName: String): Int { suspend fun installExtension(pkgName: String): Int {
logger.debug("Installing $pkgName") logger.debug("Installing $pkgName")
val extensionRecord = extensionTableAsDataClass().first { it.pkgName == pkgName } val extensionRecord = extensionTableAsDataClass().first { it.pkgName == pkgName }
@@ -189,7 +186,7 @@ object Extension {
it[name] = httpSource.name it[name] = httpSource.name
it[lang] = httpSource.lang it[lang] = httpSource.lang
it[extension] = extensionId it[extension] = extensionId
it[SourceTable.isNsfw] = isNsfw || extensionMainClassInstance.isNsfw() it[SourceTable.isNsfw] = isNsfw
} }
logger.debug { "Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}" } logger.debug { "Installed source ${httpSource.name} (${httpSource.lang}) with id:${httpSource.id}" }
} }
@@ -243,7 +240,7 @@ object Extension {
PackageTools.jarLoaderMap.remove(jarPath)?.close() PackageTools.jarLoaderMap.remove(jarPath)?.close()
// clear all loaded sources // clear all loaded sources
sources.forEach { GetHttpSource.invalidateSourceCache(it) } sources.forEach { GetCatalogueSource.unregisterCatalogueSource(it) }
File(jarPath).delete() File(jarPath).delete()
} }
@@ -0,0 +1,10 @@
package suwayomi.tachidesk.manga.impl.update
import kotlinx.coroutines.flow.StateFlow
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
interface IUpdater {
fun addMangaToQueue(manga: MangaDataClass)
fun getStatus(): StateFlow<UpdateStatus>
suspend fun reset(): Unit
}
@@ -0,0 +1,17 @@
package suwayomi.tachidesk.manga.impl.update
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
enum class JobStatus {
PENDING,
RUNNING,
COMPLETE,
FAILED
}
class UpdateJob(val manga: MangaDataClass, var status: JobStatus = JobStatus.PENDING) {
override fun toString(): String {
return "UpdateJob(status=$status, manga=${manga.title})"
}
}
@@ -0,0 +1,33 @@
package suwayomi.tachidesk.manga.impl.update
import mu.KotlinLogging
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
var logger = KotlinLogging.logger {}
class UpdateStatus(
var statusMap: MutableMap<JobStatus, MutableList<MangaDataClass>> = mutableMapOf<JobStatus, MutableList<MangaDataClass>>(),
var running: Boolean = false,
) {
var numberOfJobs: Int = 0
constructor(jobs: List<UpdateJob>, running: Boolean) : this(
mutableMapOf<JobStatus, MutableList<MangaDataClass>>(),
running
) {
this.numberOfJobs = jobs.size
jobs.forEach {
val list = statusMap.getOrDefault(it.status, mutableListOf())
list.add(it.manga)
statusMap[it.status] = list
}
}
override fun toString(): String {
return "UpdateStatus(statusMap=${statusMap.map { "${it.key} : ${it.value.size}" }.joinToString("; ")}, running=$running)"
}
// serialize to summary json
fun getJsonSummary(): String {
return """{"statusMap":{${statusMap.map { "\"${it.key}\" : ${it.value.size}" }.joinToString(",")}}, "running":$running}"""
}
}

Some files were not shown because too many files have changed in this diff Show More