Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c6d043277 | |||
| 9a226f7b64 | |||
| 3b73a0fd72 | |||
| 2478aa77cd | |||
| a41068dbc9 | |||
| 5e47b7ae6b | |||
| debf45a7d5 | |||
| e7041e8c8c | |||
| bd960992bc | |||
| 0c5f6b432c | |||
| 49232edbd5 | |||
| b02884f58d | |||
| 845b588426 | |||
| 3a97d7c8be | |||
| 2cb2ded2d9 | |||
| 14e02bee6c | |||
| 30f7cdc1ba | |||
| 420d14fc37 | |||
| 3d7953d977 | |||
| 35238b3da1 | |||
| 446f4283e0 | |||
| 8062dd3709 | |||
| 41c8fde8c5 | |||
| d90b986d19 | |||
| 64ea8416b2 | |||
| 100a4c9d35 | |||
| 4ef6dec89a | |||
| a14cdc48bd | |||
| 8a4ddbc6df | |||
| ee33acc561 | |||
| 5fe69becf3 | |||
| d460d3ccdf | |||
| 0ee74943e8 | |||
| 82837e38d2 | |||
| 91df90d760 | |||
| 1ee37da720 | |||
| 6f8fc5b69d | |||
| be1918c769 | |||
| 0057b35a0a | |||
| d12974702a | |||
| 921c41689d | |||
| 6389899507 | |||
| 92ede2a2b3 | |||
| 826a63ed71 | |||
| d1576a2a72 | |||
| 95f218d704 | |||
| 315d3a0ac0 | |||
| 954818cef2 | |||
| 5a95ca9b1b | |||
| c1e6f4c26e | |||
| 7c603258fb | |||
| fcbc582686 | |||
| 9c4906b90b |
+12
-8
@@ -1,17 +1,21 @@
|
||||
# Ignore Gradle project-specific cache directory
|
||||
# Ignore project-specific local files and dirs
|
||||
.gradle
|
||||
.idea
|
||||
gradle.properties
|
||||
|
||||
# But we need these
|
||||
!.idea/runConfigurations
|
||||
|
||||
# Ignore Gradle build output directory
|
||||
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/tmp/
|
||||
server/tachiserver-data/
|
||||
|
||||
# bundle asset downlaods
|
||||
OpenJDK*.*
|
||||
zulu*jre*
|
||||
electron-*.*
|
||||
rcedit-*
|
||||
# bundling stage downlaoded assets
|
||||
scripts/OpenJDK*
|
||||
scripts/zulu*
|
||||
scripts/electron-*
|
||||
scripts/rcedit-*
|
||||
|
||||
@@ -13,33 +13,49 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import java.util.Set;
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
public void setEntries(CharSequence[] entries) { throw new RuntimeException("Stub!"); }
|
||||
public void setValues(Set<String> values) {
|
||||
throw new RuntimeException("Stub!");
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public CharSequence[] getEntries() { throw new RuntimeException("Stub!"); }
|
||||
public Set<String> getValues() {
|
||||
throw new RuntimeException("Stub!");
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public void setEntryValues(CharSequence[] entryValues) { 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!"); }
|
||||
public int findIndexOfValue(String value) {
|
||||
throw new RuntimeException("Stub!");
|
||||
}
|
||||
|
||||
/** Tachidesk specific API */
|
||||
@Override
|
||||
public String getDefaultValueType() {
|
||||
return "Set";
|
||||
return "Set<String>";
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ package androidx.preference;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* A minimal implementation of androidx.preference.Preference
|
||||
@@ -113,18 +114,22 @@ public class Preference {
|
||||
}
|
||||
|
||||
/** Tachidesk specific API */
|
||||
@SuppressWarnings("unchecked")
|
||||
public Object getCurrentValue() {
|
||||
switch (getDefaultValueType()) {
|
||||
case "String":
|
||||
return sharedPreferences.getString(key, (String)defaultValue);
|
||||
case "Boolean":
|
||||
return sharedPreferences.getBoolean(key, (Boolean)defaultValue);
|
||||
case "Set<String>":
|
||||
return sharedPreferences.getStringSet(key, (Set<String>)defaultValue);
|
||||
default:
|
||||
throw new RuntimeException("Unsupported type");
|
||||
}
|
||||
}
|
||||
|
||||
/** Tachidesk specific API */
|
||||
@SuppressWarnings("unchecked")
|
||||
public void saveNewValue(Object value) {
|
||||
switch (getDefaultValueType()) {
|
||||
case "String":
|
||||
@@ -133,6 +138,9 @@ public class Preference {
|
||||
case "Boolean":
|
||||
sharedPreferences.edit().putBoolean(key, (Boolean)value).apply();
|
||||
break;
|
||||
case "Set<String>":
|
||||
sharedPreferences.edit().putStringSet(key, (Set<String>)value).apply();
|
||||
break;
|
||||
default:
|
||||
throw new RuntimeException("Unsupported type");
|
||||
}
|
||||
|
||||
+5
-5
@@ -12,11 +12,11 @@ import com.russhwolf.settings.ExperimentalSettingsApi
|
||||
import com.russhwolf.settings.ExperimentalSettingsImplementation
|
||||
import com.russhwolf.settings.JvmPreferencesSettings
|
||||
import com.russhwolf.settings.serialization.decodeValue
|
||||
import com.russhwolf.settings.serialization.decodeValueOrNull
|
||||
import com.russhwolf.settings.serialization.encodeValue
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.builtins.SetSerializer
|
||||
import kotlinx.serialization.builtins.nullable
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import java.util.prefs.PreferenceChangeListener
|
||||
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 {
|
||||
return if (defValues != null) {
|
||||
preferences.decodeValue(SetSerializer(String.serializer()).nullable, key, defValues)
|
||||
preferences.decodeValue(SetSerializer(String.serializer()), key, defValues)
|
||||
} else {
|
||||
preferences.decodeValue(SetSerializer(String.serializer()).nullable, key, null)
|
||||
}?.toMutableSet()
|
||||
preferences.decodeValueOrNull(SetSerializer(String.serializer()), key)
|
||||
}
|
||||
} catch (e: SerializationException) {
|
||||
throw ClassCastException("$key was not a StringSet")
|
||||
}
|
||||
|
||||
+127
@@ -1,3 +1,130 @@
|
||||
# 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
|
||||
|
||||
+17
-3
@@ -2,10 +2,20 @@
|
||||
## Where should I start?
|
||||
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?
|
||||
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.
|
||||
@@ -35,8 +45,12 @@ First Build the jar, then cd into the `scripts` directory and run `./windows-bun
|
||||
## Running in development mode
|
||||
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.
|
||||
Update the dependency declaration afterwards.
|
||||
|
||||
@@ -3,6 +3,30 @@
|
||||
|-------|----------|---------|---------|
|
||||
|  | [](https://github.com/Suwayomi/Tachidesk/releases) | [](https://github.com/Suwayomi/Tachidesk-preview/releases/latest) | [](https://discord.gg/DDZdqZWaHA) |
|
||||
|
||||
## Table of Content
|
||||
- [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/ -->
|
||||
|
||||
# What is Tachidesk?
|
||||
<img src="https://github.com/Suwayomi/Tachidesk/raw/master/server/src/main/resources/icon/faviconlogo.png" alt="drawing" width="200"/>
|
||||
|
||||
@@ -10,52 +34,41 @@ A free and open source manga reader server that runs extensions built for [Tachi
|
||||
|
||||
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.
|
||||
|
||||
Ability to read and write Tachiyomi compatible backups and syncing is a planned feature.
|
||||
Ability to sync with Tachiyomi is a planned feature.
|
||||
|
||||
# Tachidesk-Server is a server app! You may not want to Download Tachidesk-Server directly.
|
||||
Yes, you need a client/user interface app as a front-end for Tachidesk-Server, if you Directly Download Tachidesk-Server you'll get a bundled version of [Tachidesk-WebUI](https://github.com/Suwayomi/Tachidesk-WebUI) with it.
|
||||
# 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-JUI](https://github.com/Suwayomi/Tachidesk-JUI): The "official" native desktop front-end for Tachidesk-Server. Currently the most advanced.
|
||||
- [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.
|
||||
- [Equinox](https://github.com/Suwayomi/Equinox): A web user interface made with Vue.js, 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?
|
||||
Here is a list of current features:
|
||||
|
||||
- From Tachiyomi
|
||||
- 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
|
||||
- Searching and browsing installed sources
|
||||
- Ability to download Manga for offline read
|
||||
- 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
|
||||
- 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
|
||||
- Searching and browsing installed sources
|
||||
- Ability to download Manga for offline read
|
||||
- Backup and restore support powered by Tachiyomi-compatible Backups
|
||||
- Viewing latest updated chapters.
|
||||
|
||||
**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.
|
||||
|
||||
# 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
|
||||
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
|
||||
- `Tachidesk Electron Launcher`: Launches Tachidesk inside Electron as a desktop applicaton
|
||||
- `Tachidesk Browser Launcher`: Launches Tachidesk in a browser window
|
||||
@@ -85,6 +98,14 @@ You can install Tachidesk from the AUR
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -97,8 +118,33 @@ Run Container from the command line:
|
||||
$ 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
|
||||
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
|
||||
See [this troubleshooting wiki page](https://github.com/Suwayomi/Tachidesk/wiki/Troubleshooting).
|
||||
|
||||
@@ -12,9 +12,9 @@ const val kotlinVersion = "1.5.30"
|
||||
const val MainClass = "suwayomi.tachidesk.MainKt"
|
||||
|
||||
// should be bumped with each stable release
|
||||
val tachideskVersion = System.getenv("ProductVersion") ?: "v0.5.4"
|
||||
val tachideskVersion = System.getenv("ProductVersion") ?: "v0.6.0"
|
||||
|
||||
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r820"
|
||||
val webUIRevisionTag = System.getenv("WebUIRevision") ?: "r893"
|
||||
|
||||
// counts commits on the master branch
|
||||
val tachideskRevision = runCatching {
|
||||
|
||||
@@ -12,13 +12,13 @@ if [ $1 = "win32" ]; then
|
||||
jre="OpenJDK8U-jre_x86-32_windows_hotspot_8u292b10.zip"
|
||||
jre_release="jdk8u292-b10"
|
||||
jre_url="https://github.com/AdoptOpenJDK/openjdk8-binaries/releases/download/$jre_release/$jre"
|
||||
arch="win32"
|
||||
arch="windows-x86"
|
||||
electron="electron-$electron_version-win32-ia32.zip"
|
||||
else
|
||||
jre="OpenJDK8U-jre_x64_windows_hotspot_8u302b08.zip"
|
||||
jre_release="jdk8u302-b08"
|
||||
jre_url="https://github.com/adoptium/temurin8-binaries/releases/download/$jre_release/$jre"
|
||||
arch="win64"
|
||||
arch="windows-x64"
|
||||
electron="electron-$electron_version-win32-x64.zip"
|
||||
fi
|
||||
|
||||
|
||||
+10
-3
@@ -16,7 +16,8 @@ dependencies {
|
||||
implementation("com.squareup.okio:okio:2.10.0")
|
||||
|
||||
// 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`
|
||||
val jacksonVersion = "2.12.4"
|
||||
implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion")
|
||||
@@ -32,7 +33,7 @@ dependencies {
|
||||
implementation("com.h2database:h2:1.4.200")
|
||||
|
||||
// Exposed Migrations
|
||||
implementation("com.github.Suwayomi:exposed-migrations:3.1.2")
|
||||
implementation("com.github.Suwayomi:exposed-migrations:3.1.4")
|
||||
|
||||
// tray icon
|
||||
implementation("com.dorkbox:SystemTray:4.1")
|
||||
@@ -70,6 +71,8 @@ dependencies {
|
||||
// uncomment to test extensions directly
|
||||
// implementation(fileTree("lib/"))
|
||||
implementation(kotlin("script-runtime"))
|
||||
|
||||
testImplementation("io.mockk:mockk:1.9.3")
|
||||
}
|
||||
|
||||
application {
|
||||
@@ -125,7 +128,11 @@ tasks {
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnit()
|
||||
useJUnitPlatform()
|
||||
testLogging {
|
||||
showStandardStreams = true
|
||||
events("passed", "skipped", "failed")
|
||||
}
|
||||
}
|
||||
|
||||
named<Copy>("processResources") {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
-25
@@ -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) }
|
||||
}
|
||||
-206
@@ -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
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import org.kodein.di.DI
|
||||
import org.kodein.di.conf.global
|
||||
import org.kodein.di.instance
|
||||
import rx.Observable
|
||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.registerCatalogueSource
|
||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
|
||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||
@@ -89,7 +90,7 @@ class LocalSource : CatalogueSource {
|
||||
}
|
||||
}
|
||||
|
||||
fun addDbRecords() {
|
||||
fun register() {
|
||||
transaction {
|
||||
val sourceRecord = SourceTable.select { SourceTable.id eq ID }.firstOrNull()
|
||||
|
||||
@@ -115,6 +116,8 @@ class LocalSource : CatalogueSource {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerCatalogueSource(ID to LocalSource())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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 Separator(name: String = "") : Filter<Any>(name, 0)
|
||||
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] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-79
@@ -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
|
||||
}
|
||||
}
|
||||
-19
@@ -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
|
||||
)
|
||||
-24
@@ -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,
|
||||
)
|
||||
-16
@@ -1,16 +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 AnimeSourceDataClass(
|
||||
val id: String,
|
||||
val name: String?,
|
||||
val lang: String?,
|
||||
val iconUrl: String?,
|
||||
val supportsLatest: 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() }
|
||||
)
|
||||
@@ -44,14 +44,15 @@ object MangaAPI {
|
||||
get("{sourceId}/preferences", SourceController::getPreferences)
|
||||
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("search/{searchTerm}/{pageNum}", SourceController::searchGlobal)
|
||||
get("{sourceId}/search", SourceController::searchSingle)
|
||||
// get("all/search", SourceController::searchGlobal) // TODO
|
||||
}
|
||||
|
||||
path("manga") {
|
||||
get("{mangaId}", MangaController::retrieve)
|
||||
get("{mangaId}", MangaController.retrieve)
|
||||
get("{mangaId}/thumbnail", MangaController::thumbnail)
|
||||
|
||||
get("{mangaId}/category", MangaController::categoryList)
|
||||
@@ -77,11 +78,13 @@ object MangaAPI {
|
||||
get("", CategoryController::categoryList)
|
||||
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)
|
||||
patch("{categoryId}", CategoryController::categoryModify)
|
||||
delete("{categoryId}", CategoryController::categoryDelete)
|
||||
|
||||
patch("reorder", CategoryController::categoryReorder)
|
||||
}
|
||||
|
||||
path("backup") {
|
||||
@@ -109,7 +112,10 @@ object MangaAPI {
|
||||
}
|
||||
|
||||
path("update") {
|
||||
get("recentChapters", UpdateController::recentChapters)
|
||||
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.websocket.WsConfig
|
||||
import suwayomi.tachidesk.manga.impl.download.DownloadManager
|
||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||
|
||||
object DownloadController {
|
||||
/** Download queue stats */
|
||||
@@ -52,9 +53,11 @@ object DownloadController {
|
||||
val chapterIndex = ctx.pathParam("chapterIndex").toInt()
|
||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
||||
|
||||
DownloadManager.enqueue(chapterIndex, mangaId)
|
||||
|
||||
ctx.status(200)
|
||||
ctx.future(
|
||||
future {
|
||||
DownloadManager.enqueue(chapterIndex, mangaId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** 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.Manga
|
||||
import suwayomi.tachidesk.manga.impl.Page
|
||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||
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 {
|
||||
/** get manga info */
|
||||
fun retrieve(ctx: Context) {
|
||||
val mangaId = ctx.pathParam("mangaId").toInt()
|
||||
val onlineFetch = ctx.queryParam("onlineFetch")?.toBoolean() ?: false
|
||||
|
||||
ctx.future(
|
||||
future {
|
||||
Manga.getManga(mangaId, onlineFetch)
|
||||
val retrieve = handler(
|
||||
pathParam<Int>("mangaId"),
|
||||
queryParam("onlineFetch", false),
|
||||
documentWith = {
|
||||
withOperation {
|
||||
summary("Get a manga")
|
||||
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 */
|
||||
fun thumbnail(ctx: Context) {
|
||||
@@ -37,6 +52,8 @@ object MangaController {
|
||||
future { Manga.getMangaThumbnail(mangaId, useCache) }
|
||||
.thenApply {
|
||||
ctx.header("content-type", it.second)
|
||||
val httpCacheSeconds = 60 * 60 * 24
|
||||
ctx.header("cache-control", "max-age=$httpCacheSeconds")
|
||||
it.first
|
||||
}
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ package suwayomi.tachidesk.manga.controller
|
||||
import io.javalin.http.Context
|
||||
import suwayomi.tachidesk.manga.impl.MangaList
|
||||
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.SourcePreferenceChange
|
||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||
@@ -54,7 +55,7 @@ object SourceController {
|
||||
ctx.json(Source.getSourcePreferences(sourceId))
|
||||
}
|
||||
|
||||
/** fetch preferences of source with id `sourceId` */
|
||||
/** set one preference of source with id `sourceId` */
|
||||
fun setPreference(ctx: Context) {
|
||||
val sourceId = ctx.pathParam("sourceId").toLong()
|
||||
val preferenceChange = ctx.bodyAsClass(SourcePreferenceChange::class.java)
|
||||
@@ -62,18 +63,25 @@ object SourceController {
|
||||
}
|
||||
|
||||
/** fetch filters of source with id `sourceId` */
|
||||
fun filters(ctx: Context) {
|
||||
fun getFilters(ctx: Context) {
|
||||
val sourceId = ctx.pathParam("sourceId").toLong()
|
||||
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 */
|
||||
fun searchSingle(ctx: Context) {
|
||||
val sourceId = ctx.pathParam("sourceId").toLong()
|
||||
val searchTerm = ctx.pathParam("searchTerm")
|
||||
val pageNum = ctx.pathParam("pageNum").toInt()
|
||||
val searchTerm = ctx.queryParam("searchTerm") ?: ""
|
||||
val pageNum = ctx.queryParam("pageNum")?.toInt() ?: 1
|
||||
ctx.future(future { Search.sourceSearch(sourceId, searchTerm, pageNum) })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
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
|
||||
|
||||
/*
|
||||
@@ -12,12 +24,66 @@ import suwayomi.tachidesk.server.JavalinSetup.future
|
||||
* 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()
|
||||
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.and
|
||||
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.selectAll
|
||||
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
|
||||
*/
|
||||
fun createCategory(name: String) {
|
||||
fun createCategory(name: String): Int {
|
||||
// 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) {
|
||||
CategoryTable.insert {
|
||||
val newCategoryId = CategoryTable.insertAndGetId {
|
||||
it[CategoryTable.name] = name
|
||||
it[CategoryTable.order] = Int.MAX_VALUE
|
||||
}
|
||||
}.value
|
||||
|
||||
normalizeCategories()
|
||||
}
|
||||
|
||||
newCategoryId
|
||||
} else -1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,4 +109,12 @@ object Category {
|
||||
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
|
||||
* 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.and
|
||||
import org.jetbrains.exposed.sql.count
|
||||
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.jetbrains.exposed.sql.wrapAsExpression
|
||||
import suwayomi.tachidesk.manga.impl.Category.DEFAULT_CATEGORY_ID
|
||||
import suwayomi.tachidesk.manga.impl.util.lang.isEmpty
|
||||
import suwayomi.tachidesk.manga.model.dataclass.CategoryDataClass
|
||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
||||
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.toDataClass
|
||||
|
||||
@@ -56,17 +60,38 @@ object CategoryManga {
|
||||
* list of mangas that belong to a category
|
||||
*/
|
||||
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)
|
||||
return transaction {
|
||||
MangaTable.select { (MangaTable.inLibrary eq true) and (MangaTable.defaultCategory eq true) }.map {
|
||||
MangaTable.toDataClass(it)
|
||||
}
|
||||
MangaTable
|
||||
.slice(selectedColumns)
|
||||
.select { (MangaTable.inLibrary eq true) and (MangaTable.defaultCategory eq true) }
|
||||
.map(transform)
|
||||
}
|
||||
|
||||
return transaction {
|
||||
CategoryMangaTable.innerJoin(MangaTable).select { CategoryMangaTable.category eq categoryId }.map {
|
||||
MangaTable.toDataClass(it)
|
||||
}
|
||||
CategoryMangaTable.innerJoin(MangaTable)
|
||||
.slice(selectedColumns)
|
||||
.select { CategoryMangaTable.category eq categoryId }
|
||||
.map(transform)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogue
|
||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageResponse
|
||||
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
||||
import suwayomi.tachidesk.manga.model.dataclass.MangaChapterDataClass
|
||||
import suwayomi.tachidesk.manga.model.dataclass.PaginatedList
|
||||
import suwayomi.tachidesk.manga.model.dataclass.paginatedFrom
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterMetaTable
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
@@ -42,7 +44,8 @@ object Chapter {
|
||||
getSourceChapters(mangaId)
|
||||
} else {
|
||||
transaction {
|
||||
ChapterTable.select { ChapterTable.manga eq mangaId }.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
|
||||
ChapterTable.select { ChapterTable.manga eq mangaId }
|
||||
.orderBy(ChapterTable.sourceOrder to SortOrder.DESC)
|
||||
.map {
|
||||
ChapterTable.toDataClass(it)
|
||||
}
|
||||
@@ -105,14 +108,13 @@ object Chapter {
|
||||
val dbChapterCount = transaction { ChapterTable.select { ChapterTable.manga eq mangaId }.count() }
|
||||
if (dbChapterCount > chapterCount) { // we got some clean up due
|
||||
val dbChapterList = transaction { ChapterTable.select { ChapterTable.manga eq mangaId }.toList() }
|
||||
val chapterUrls = chapterList.map { it.url }.toSet()
|
||||
|
||||
dbChapterList.forEach {
|
||||
if (it[ChapterTable.sourceOrder] >= chapterList.size ||
|
||||
chapterList[it[ChapterTable.sourceOrder] - 1].url != it[ChapterTable.url]
|
||||
) {
|
||||
dbChapterList.forEach { dbChapter ->
|
||||
if (!chapterUrls.contains(dbChapter[ChapterTable.url])) {
|
||||
transaction {
|
||||
PageTable.deleteWhere { PageTable.chapter eq it[ChapterTable.id] }
|
||||
ChapterTable.deleteWhere { ChapterTable.id eq it[ChapterTable.id] }
|
||||
PageTable.deleteWhere { PageTable.chapter eq dbChapter[ChapterTable.id] }
|
||||
ChapterTable.deleteWhere { ChapterTable.id eq dbChapter[ChapterTable.id] }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,9 +162,12 @@ object Chapter {
|
||||
}.first()
|
||||
}
|
||||
|
||||
val isReallyDownloaded =
|
||||
chapterEntry[ChapterTable.isDownloaded] && firstPageExists(mangaId, chapterEntry[ChapterTable.id].value)
|
||||
return if (!isReallyDownloaded) {
|
||||
val isPartiallyDownloaded =
|
||||
!(chapterEntry[ChapterTable.isDownloaded] && firstPageExists(mangaId, chapterEntry[ChapterTable.id].value))
|
||||
|
||||
return if (isPartiallyDownloaded) {
|
||||
|
||||
// chapter files may have been deleted
|
||||
transaction {
|
||||
ChapterTable.update({ (ChapterTable.sourceOrder eq chapterIndex) and (ChapterTable.manga eq mangaId) }) {
|
||||
it[isDownloaded] = false
|
||||
@@ -241,7 +246,7 @@ object Chapter {
|
||||
|
||||
return ImageResponse.findFileNameStartingWith(
|
||||
chapterDir,
|
||||
getPageName(0, chapterDir)
|
||||
getPageName(1)
|
||||
) != null
|
||||
}
|
||||
|
||||
@@ -321,17 +326,19 @@ object Chapter {
|
||||
}
|
||||
}
|
||||
|
||||
fun getRecentChapters(): List<MangaChapterDataClass> {
|
||||
return transaction {
|
||||
(ChapterTable innerJoin MangaTable)
|
||||
.select { (MangaTable.inLibrary eq true) and (ChapterTable.fetchedAt greater MangaTable.inLibraryAt) }
|
||||
.orderBy(ChapterTable.fetchedAt to SortOrder.DESC)
|
||||
.map {
|
||||
MangaChapterDataClass(
|
||||
MangaTable.toDataClass(it),
|
||||
ChapterTable.toDataClass(it)
|
||||
)
|
||||
}
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ object Manga {
|
||||
|
||||
fun modifyMangaMeta(mangaId: Int, key: String, value: String) {
|
||||
transaction {
|
||||
val manga = MangaMetaTable.select { (MangaTable.id eq mangaId) }
|
||||
val manga = MangaTable.select { MangaTable.id eq mangaId }
|
||||
.first()[MangaTable.id]
|
||||
val meta =
|
||||
transaction { MangaMetaTable.select { (MangaMetaTable.ref eq manga) and (MangaMetaTable.key eq key) } }.firstOrNull()
|
||||
@@ -164,7 +164,7 @@ object Manga {
|
||||
|
||||
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
||||
suspend fun getMangaThumbnail(mangaId: Int, useCache: Boolean): Pair<InputStream, String> {
|
||||
val saveDir = applicationDirs.mangaThumbnailsRoot
|
||||
val saveDir = applicationDirs.thumbnailsRoot
|
||||
val fileName = mangaId.toString()
|
||||
|
||||
val mangaEntry = transaction { MangaTable.select { MangaTable.id eq mangaId }.first() }
|
||||
@@ -204,7 +204,7 @@ object Manga {
|
||||
}
|
||||
|
||||
private fun clearMangaThumbnail(mangaId: Int) {
|
||||
val saveDir = applicationDirs.mangaThumbnailsRoot
|
||||
val saveDir = applicationDirs.thumbnailsRoot
|
||||
val fileName = mangaId.toString()
|
||||
|
||||
clearCachedImage(saveDir, fileName)
|
||||
|
||||
@@ -14,19 +14,14 @@ import org.jetbrains.exposed.sql.and
|
||||
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.manga.impl.util.getChapterDir
|
||||
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.getImageResponse
|
||||
import suwayomi.tachidesk.manga.impl.util.storage.ImageUtil
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.PageTable
|
||||
import suwayomi.tachidesk.server.ApplicationDirs
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
|
||||
@@ -87,26 +82,15 @@ object Page {
|
||||
|
||||
val chapterDir = getChapterDir(mangaId, chapterId)
|
||||
File(chapterDir).mkdirs()
|
||||
val fileName = getPageName(index, chapterDir) // e.g. 001
|
||||
val fileName = getPageName(index)
|
||||
|
||||
return getImageResponse(chapterDir, fileName, useCache) {
|
||||
source.fetchImage(tachiyomiPage).awaitSingle()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(v0.6.0) : zero based pages are deprecated
|
||||
fun getPageName(index: Int, chapterDir: String): String {
|
||||
val zeroBasedPageExists = ImageResponse.findFileNameStartingWith(
|
||||
chapterDir,
|
||||
formatPageName(0)
|
||||
) != null
|
||||
|
||||
if (zeroBasedPageExists) return formatPageName(index)
|
||||
|
||||
return formatPageName(index + 1)
|
||||
/** converts 0 to "001" */
|
||||
fun getPageName(index: Int): String {
|
||||
return String.format("%03d", index + 1)
|
||||
}
|
||||
|
||||
private fun formatPageName(index: Int) = String.format("%03d", index)
|
||||
|
||||
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
||||
}
|
||||
|
||||
@@ -7,8 +7,13 @@ package suwayomi.tachidesk.manga.impl
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
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.util.lang.awaitSingle
|
||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
|
||||
@@ -17,89 +22,102 @@ import suwayomi.tachidesk.manga.model.dataclass.PagedMangaListDataClass
|
||||
object Search {
|
||||
suspend fun sourceSearch(sourceId: Long, searchTerm: String, pageNum: Int): PagedMangaListDataClass {
|
||||
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)
|
||||
}
|
||||
|
||||
private val filterListCache = mutableMapOf<Long, FilterList>()
|
||||
|
||||
private fun getFilterListOf(sourceId: Long, reset: Boolean = false): FilterList {
|
||||
if (reset || !filterListCache.containsKey(sourceId)) {
|
||||
filterListCache[sourceId] = getCatalogueSourceOrStub(sourceId).getFilterList()
|
||||
private fun getFilterListOf(source: CatalogueSource, reset: Boolean = false): FilterList {
|
||||
if (reset || !filterListCache.containsKey(source.id)) {
|
||||
filterListCache[source.id] = source.getFilterList()
|
||||
}
|
||||
return filterListCache[sourceId]!!
|
||||
return filterListCache[source.id]!!
|
||||
}
|
||||
|
||||
fun getInitialFilterList(sourceId: Long, reset: Boolean): List<FilterObject> {
|
||||
return getFilterListOf(sourceId, reset).list.map {
|
||||
fun getFilterList(sourceId: Long, reset: Boolean): List<FilterObject> {
|
||||
val source = getCatalogueSourceOrStub(sourceId)
|
||||
|
||||
return getFilterListOf(source, reset).list.map {
|
||||
FilterObject(
|
||||
when (it) {
|
||||
is Filter.Header -> "Header"
|
||||
is Filter.Separator -> "Separator"
|
||||
is Filter.Select<*> -> "Select"
|
||||
is Filter.Text -> "Text"
|
||||
is Filter.CheckBox -> "CheckBox"
|
||||
is Filter.TriState -> "TriState"
|
||||
is Filter.Text -> "Text"
|
||||
is Filter.Select<*> -> "Select"
|
||||
is Filter.Group<*> -> "Group"
|
||||
is Filter.Sort -> "Sort"
|
||||
else -> throw RuntimeException("sealed class Cannot have more Subtypes!")
|
||||
},
|
||||
// when (it) {
|
||||
// is Filter.Select<*> -> it.getValuesType()
|
||||
// else -> null
|
||||
// },
|
||||
it
|
||||
when (it) {
|
||||
is Filter.Group<*> -> {
|
||||
SerializableGroup(
|
||||
it.name,
|
||||
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(
|
||||
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")
|
||||
fun sourceGlobalSearch(searchTerm: String) {
|
||||
// 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,6 +12,7 @@ import android.content.Context
|
||||
import androidx.preference.PreferenceScreen
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
import eu.kanade.tachiyomi.source.getPreferenceKey
|
||||
import io.javalin.plugin.json.JsonMapper
|
||||
import mu.KotlinLogging
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
@@ -22,7 +23,7 @@ import org.kodein.di.instance
|
||||
import suwayomi.tachidesk.manga.impl.extension.Extension.getExtensionIconUrl
|
||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSource
|
||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSourceOrStub
|
||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.invalidateSourceCache
|
||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.unregisterCatalogueSource
|
||||
import suwayomi.tachidesk.manga.model.dataclass.SourceDataClass
|
||||
import suwayomi.tachidesk.manga.model.table.ExtensionTable
|
||||
import suwayomi.tachidesk.manga.model.table.SourceTable
|
||||
@@ -81,11 +82,12 @@ object Source {
|
||||
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
|
||||
* - SwitchPreferenceCompat
|
||||
* - ListPreference
|
||||
* - CheckBoxPreference
|
||||
* - MultiSelectListPreference
|
||||
*/
|
||||
data class PreferenceObject(
|
||||
val type: String,
|
||||
@@ -123,20 +125,25 @@ object Source {
|
||||
val value: String
|
||||
)
|
||||
|
||||
private val jsonMapper by DI.global.instance<JsonMapper>()
|
||||
|
||||
@Suppress("IMPLICIT_CAST_TO_ANY", "UNCHECKED_CAST")
|
||||
fun setSourcePreference(sourceId: Long, change: SourcePreferenceChange) {
|
||||
val screen = preferenceScreenMap[sourceId]!!
|
||||
val pref = screen.preferences[change.position]
|
||||
|
||||
println(jsonMapper::class.java.name)
|
||||
val newValue = when (pref.defaultValueType) {
|
||||
"String" -> change.value
|
||||
"Boolean" -> change.value.toBoolean()
|
||||
"Set<String>" -> jsonMapper.fromJsonString(change.value, List::class.java as Class<List<String>>).toSet()
|
||||
else -> throw RuntimeException("Unsupported type conversion")
|
||||
}
|
||||
|
||||
pref.saveNewValue(newValue)
|
||||
pref.callChangeListener(newValue)
|
||||
|
||||
// must reload the source cache because a preference was changed
|
||||
invalidateSourceCache(sourceId)
|
||||
// must reload the source because a preference was changed
|
||||
unregisterCatalogueSource(sourceId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import io.javalin.websocket.WsMessageContext
|
||||
import org.jetbrains.exposed.sql.and
|
||||
import org.jetbrains.exposed.sql.select
|
||||
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.DownloadState.Downloading
|
||||
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 }) {
|
||||
downloadQueue.add(
|
||||
DownloadChapter(
|
||||
@@ -80,7 +81,8 @@ object DownloadManager {
|
||||
ChapterTable.select { (ChapterTable.manga eq mangaId) and (ChapterTable.sourceOrder eq chapterIndex) }
|
||||
.first()
|
||||
}
|
||||
)
|
||||
),
|
||||
manga = getManga(mangaId)
|
||||
)
|
||||
)
|
||||
start()
|
||||
|
||||
@@ -50,7 +50,7 @@ class Downloader(private val downloadQueue: CopyOnWriteArrayList<DownloadChapter
|
||||
download.chapter = runBlocking { getChapter(download.chapterIndex, download.mangaId) }
|
||||
step()
|
||||
|
||||
val pageCount = download.chapter!!.pageCount
|
||||
val pageCount = download.chapter.pageCount
|
||||
for (pageNum in 0 until pageCount) {
|
||||
runBlocking { getPageImage(download.mangaId, download.chapterIndex, pageNum) }
|
||||
// TODO: retry on error with 2,4,8 seconds of wait
|
||||
|
||||
+3
-1
@@ -9,12 +9,14 @@ package suwayomi.tachidesk.manga.impl.download.model
|
||||
|
||||
import suwayomi.tachidesk.manga.impl.download.model.DownloadState.Queued
|
||||
import suwayomi.tachidesk.manga.model.dataclass.ChapterDataClass
|
||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||
|
||||
class DownloadChapter(
|
||||
val chapterIndex: Int,
|
||||
val mangaId: Int,
|
||||
var chapter: ChapterDataClass,
|
||||
var manga: MangaDataClass,
|
||||
var state: DownloadState = Queued,
|
||||
var progress: Float = 0f,
|
||||
var tries: Int = 0,
|
||||
var chapter: ChapterDataClass? = null,
|
||||
)
|
||||
|
||||
@@ -240,7 +240,7 @@ object Extension {
|
||||
PackageTools.jarLoaderMap.remove(jarPath)?.close()
|
||||
|
||||
// clear all loaded sources
|
||||
sources.forEach { GetCatalogueSource.invalidateSourceCache(it) }
|
||||
sources.forEach { GetCatalogueSource.unregisterCatalogueSource(it) }
|
||||
|
||||
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}"""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package suwayomi.tachidesk.manga.impl.update
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import mu.KotlinLogging
|
||||
import suwayomi.tachidesk.manga.impl.Chapter
|
||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||
|
||||
class Updater : IUpdater {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private var tracker = mutableMapOf<String, UpdateJob>()
|
||||
private var updateChannel = Channel<UpdateJob>()
|
||||
private val statusChannel = MutableStateFlow(UpdateStatus())
|
||||
private var updateJob: Job? = null
|
||||
|
||||
init {
|
||||
updateJob = createUpdateJob()
|
||||
}
|
||||
|
||||
private fun createUpdateJob(): Job {
|
||||
return scope.launch {
|
||||
while (true) {
|
||||
val job = updateChannel.receive()
|
||||
process(job)
|
||||
statusChannel.value = UpdateStatus(tracker.values.toList(), !updateChannel.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun process(job: UpdateJob) {
|
||||
job.status = JobStatus.RUNNING
|
||||
tracker["${job.manga.id}"] = job
|
||||
statusChannel.value = UpdateStatus(tracker.values.toList(), true)
|
||||
try {
|
||||
logger.info { "Updating ${job.manga.title}" }
|
||||
Chapter.getChapterList(job.manga.id, true)
|
||||
job.status = JobStatus.COMPLETE
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
logger.error(e) { "Error while updating ${job.manga.title}" }
|
||||
job.status = JobStatus.FAILED
|
||||
}
|
||||
tracker["${job.manga.id}"] = job
|
||||
}
|
||||
|
||||
override fun addMangaToQueue(manga: MangaDataClass) {
|
||||
scope.launch {
|
||||
updateChannel.send(UpdateJob(manga))
|
||||
}
|
||||
tracker["${manga.id}"] = UpdateJob(manga)
|
||||
statusChannel.value = UpdateStatus(tracker.values.toList(), true)
|
||||
}
|
||||
|
||||
override fun getStatus(): StateFlow<UpdateStatus> {
|
||||
return statusChannel
|
||||
}
|
||||
|
||||
override suspend fun reset() {
|
||||
tracker.clear()
|
||||
updateChannel.cancel()
|
||||
statusChannel.value = UpdateStatus()
|
||||
updateJob?.cancel("Reset")
|
||||
updateChannel = Channel()
|
||||
updateJob = createUpdateJob()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package suwayomi.tachidesk.manga.impl.update
|
||||
|
||||
import io.javalin.websocket.WsContext
|
||||
import io.javalin.websocket.WsMessageContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import mu.KotlinLogging
|
||||
import org.kodein.di.DI
|
||||
import org.kodein.di.conf.global
|
||||
import org.kodein.di.instance
|
||||
|
||||
object UpdaterSocket : Websocket() {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val updater by DI.global.instance<IUpdater>()
|
||||
private var job: Job? = null
|
||||
|
||||
override fun notifyClient(ctx: WsContext) {
|
||||
ctx.send(updater.getStatus().value.getJsonSummary())
|
||||
}
|
||||
|
||||
override fun handleRequest(ctx: WsMessageContext) {
|
||||
when (ctx.message()) {
|
||||
"STATUS" -> notifyClient(ctx)
|
||||
else -> ctx.send(
|
||||
"""
|
||||
|Invalid command.
|
||||
|Supported commands are:
|
||||
| - STATUS
|
||||
| sends the current update status
|
||||
|""".trimMargin()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun addClient(ctx: WsContext) {
|
||||
logger.info { ctx.sessionId }
|
||||
super.addClient(ctx)
|
||||
if (job == null) {
|
||||
job = start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun removeClient(ctx: WsContext) {
|
||||
super.removeClient(ctx)
|
||||
if (clients.isEmpty()) {
|
||||
job?.cancel()
|
||||
job = null
|
||||
}
|
||||
}
|
||||
|
||||
fun start(): Job {
|
||||
return scope.launch {
|
||||
while (true) {
|
||||
updater.getStatus().collectLatest {
|
||||
notifyAllClients()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package suwayomi.tachidesk.manga.impl.update
|
||||
|
||||
import io.javalin.websocket.WsContext
|
||||
import io.javalin.websocket.WsMessageContext
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
abstract class Websocket {
|
||||
protected val clients = ConcurrentHashMap<String, WsContext>()
|
||||
open fun addClient(ctx: WsContext) {
|
||||
clients[ctx.sessionId] = ctx
|
||||
notifyClient(ctx)
|
||||
}
|
||||
open fun removeClient(ctx: WsContext) {
|
||||
clients.remove(ctx.sessionId)
|
||||
}
|
||||
open fun notifyAllClients() {
|
||||
clients.values.forEach { notifyClient(it) }
|
||||
}
|
||||
abstract fun notifyClient(ctx: WsContext)
|
||||
abstract fun handleRequest(ctx: WsMessageContext)
|
||||
}
|
||||
@@ -28,7 +28,7 @@ fun getMangaDir(mangaId: Int): String {
|
||||
val sourceDir = source.toString()
|
||||
val mangaDir = SafePath.buildValidFilename(mangaEntry[MangaTable.title])
|
||||
|
||||
return "${applicationDirs.mangaRoot}/$sourceDir/$mangaDir"
|
||||
return "${applicationDirs.mangaDownloadsRoot}/$sourceDir/$mangaDir"
|
||||
}
|
||||
|
||||
fun getChapterDir(mangaId: Int, chapterId: Int): String {
|
||||
@@ -54,8 +54,8 @@ fun updateMangaDownloadDir(mangaId: Int, newTitle: String): Boolean {
|
||||
|
||||
val newMangaDir = SafePath.buildValidFilename(newTitle)
|
||||
|
||||
val oldDir = "${applicationDirs.mangaRoot}/$sourceDir/$mangaDir"
|
||||
val newDir = "${applicationDirs.mangaRoot}/$sourceDir/$newMangaDir"
|
||||
val oldDir = "${applicationDirs.mangaDownloadsRoot}/$sourceDir/$mangaDir"
|
||||
val newDir = "${applicationDirs.mangaDownloadsRoot}/$sourceDir/$newMangaDir"
|
||||
|
||||
val oldDirFile = File(oldDir)
|
||||
val newDirFile = File(newDir)
|
||||
|
||||
+4
-6
@@ -1,4 +1,6 @@
|
||||
package suwayomi.tachidesk.anime.model.dataclass
|
||||
package suwayomi.tachidesk.manga.impl.util.lang
|
||||
|
||||
import java.io.File
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
@@ -7,8 +9,4 @@ package suwayomi.tachidesk.anime.model.dataclass
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
data class VideoDataClass(
|
||||
val url: String,
|
||||
val quality: String,
|
||||
var videoUrl: String?,
|
||||
)
|
||||
fun File.renameTo(newPath: String) = renameTo(File(newPath))
|
||||
+6
-5
@@ -10,7 +10,6 @@ package suwayomi.tachidesk.manga.impl.util.source
|
||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
import eu.kanade.tachiyomi.source.local.LocalSource
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import org.jetbrains.exposed.sql.select
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
@@ -24,9 +23,7 @@ import suwayomi.tachidesk.server.ApplicationDirs
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object GetCatalogueSource {
|
||||
private val sourceCache = ConcurrentHashMap<Long, CatalogueSource>(
|
||||
mapOf(LocalSource.ID to LocalSource())
|
||||
)
|
||||
private val sourceCache = ConcurrentHashMap<Long, CatalogueSource>()
|
||||
private val applicationDirs by DI.global.instance<ApplicationDirs>()
|
||||
|
||||
fun getCatalogueSource(sourceId: Long): CatalogueSource? {
|
||||
@@ -63,7 +60,11 @@ object GetCatalogueSource {
|
||||
return getCatalogueSource(sourceId) ?: StubSource(sourceId)
|
||||
}
|
||||
|
||||
fun invalidateSourceCache(sourceId: Long) {
|
||||
fun registerCatalogueSource(sourcePair: Pair<Long, CatalogueSource>) {
|
||||
sourceCache += sourcePair
|
||||
}
|
||||
|
||||
fun unregisterCatalogueSource(sourceId: Long) {
|
||||
sourceCache.remove(sourceId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import rx.Observable
|
||||
|
||||
class StubSource(override val id: Long) : CatalogueSource {
|
||||
open class StubSource(override val id: Long) : CatalogueSource {
|
||||
override val lang: String = "other"
|
||||
override val supportsLatest: Boolean = false
|
||||
override val name: String
|
||||
|
||||
@@ -35,6 +35,8 @@ data class MangaDataClass(
|
||||
val realUrl: String? = null,
|
||||
|
||||
val freshData: Boolean = false,
|
||||
var unreadCount: Int? = null,
|
||||
var downloadCount: Int? = null
|
||||
)
|
||||
|
||||
data class PagedMangaListDataClass(
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package suwayomi.tachidesk.manga.model.dataclass
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import kotlin.math.min
|
||||
|
||||
data class PaginatedList<T>(
|
||||
val page: List<T>,
|
||||
val hasNextPage: Boolean,
|
||||
)
|
||||
|
||||
const val PaginationFactor = 50
|
||||
|
||||
fun <T> paginatedFrom(
|
||||
pageNum: Int,
|
||||
paginationFactor: Int = PaginationFactor,
|
||||
lister: () -> List<T>
|
||||
): PaginatedList<T> {
|
||||
val list = lister()
|
||||
val lastIndex = list.size - 1
|
||||
|
||||
val lowerIndex = pageNum * paginationFactor
|
||||
val higherIndex = (pageNum + 1) * paginationFactor - 1
|
||||
|
||||
if (lowerIndex > lastIndex) {
|
||||
return PaginatedList(emptyList(), false)
|
||||
}
|
||||
|
||||
val sliced = list.slice(lowerIndex..min(lastIndex, higherIndex))
|
||||
|
||||
return PaginatedList(
|
||||
sliced,
|
||||
higherIndex < lastIndex
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import io.javalin.Javalin
|
||||
import io.javalin.apibuilder.ApiBuilder.path
|
||||
import io.javalin.core.security.RouteRole
|
||||
import io.javalin.http.staticfiles.Location
|
||||
import io.javalin.plugin.openapi.OpenApiOptions
|
||||
import io.javalin.plugin.openapi.OpenApiPlugin
|
||||
import io.javalin.plugin.openapi.ui.SwaggerOptions
|
||||
import io.swagger.v3.oas.models.info.Info
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -19,7 +23,6 @@ import mu.KotlinLogging
|
||||
import org.kodein.di.DI
|
||||
import org.kodein.di.conf.global
|
||||
import org.kodein.di.instance
|
||||
import suwayomi.tachidesk.anime.AnimeAPI
|
||||
import suwayomi.tachidesk.global.GlobalAPI
|
||||
import suwayomi.tachidesk.manga.MangaAPI
|
||||
import suwayomi.tachidesk.server.util.Browser
|
||||
@@ -47,6 +50,7 @@ object JavalinSetup {
|
||||
logger.info { "Serving webUI static files" }
|
||||
config.addStaticFiles(applicationDirs.webUIRoot, Location.EXTERNAL)
|
||||
config.addSinglePageRoot("/", applicationDirs.webUIRoot + "/index.html", Location.EXTERNAL)
|
||||
config.registerPlugin(OpenApiPlugin(getOpenApiOptions()))
|
||||
}
|
||||
|
||||
config.enableCorsForAllOrigins()
|
||||
@@ -95,11 +99,25 @@ object JavalinSetup {
|
||||
path("api/v1/") {
|
||||
GlobalAPI.defineEndpoints()
|
||||
MangaAPI.defineEndpoints()
|
||||
AnimeAPI.defineEndpoints(app) // TODO: migrate Anime endpoints
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOpenApiOptions(): OpenApiOptions {
|
||||
val applicationInfo = Info().apply {
|
||||
version("1.0")
|
||||
description("Tachidesk Api")
|
||||
}
|
||||
return OpenApiOptions(applicationInfo).apply {
|
||||
path("/api/openapi.json")
|
||||
swagger(
|
||||
SwaggerOptions("/api/swagger-ui").apply {
|
||||
title("Tachidesk Swagger Documentation")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object Auth {
|
||||
enum class Role : RouteRole { ANYONE, USER_READ, USER_WRITE }
|
||||
}
|
||||
|
||||
@@ -9,11 +9,16 @@ package suwayomi.tachidesk.server
|
||||
|
||||
import eu.kanade.tachiyomi.App
|
||||
import eu.kanade.tachiyomi.source.local.LocalSource
|
||||
import io.javalin.plugin.json.JavalinJackson
|
||||
import io.javalin.plugin.json.JsonMapper
|
||||
import mu.KotlinLogging
|
||||
import org.kodein.di.DI
|
||||
import org.kodein.di.bind
|
||||
import org.kodein.di.conf.global
|
||||
import org.kodein.di.singleton
|
||||
import suwayomi.tachidesk.manga.impl.update.IUpdater
|
||||
import suwayomi.tachidesk.manga.impl.update.Updater
|
||||
import suwayomi.tachidesk.manga.impl.util.lang.renameTo
|
||||
import suwayomi.tachidesk.server.database.databaseUp
|
||||
import suwayomi.tachidesk.server.util.AppMutex.handleAppMutex
|
||||
import suwayomi.tachidesk.server.util.SystemTray.systemTray
|
||||
@@ -31,10 +36,9 @@ class ApplicationDirs(
|
||||
val dataRoot: String = ApplicationRootDir
|
||||
) {
|
||||
val extensionsRoot = "$dataRoot/extensions"
|
||||
val mangaThumbnailsRoot = "$dataRoot/manga-thumbnails"
|
||||
val animeThumbnailsRoot = "$dataRoot/anime-thumbnails"
|
||||
val mangaRoot = "$dataRoot/manga"
|
||||
val localMangaRoot = "$dataRoot/manga-local"
|
||||
val thumbnailsRoot = "$dataRoot/thumbnails"
|
||||
val mangaDownloadsRoot = "$dataRoot/downloads"
|
||||
val localMangaRoot = "$dataRoot/local"
|
||||
val webUIRoot = "$dataRoot/webUI"
|
||||
}
|
||||
|
||||
@@ -53,19 +57,26 @@ fun applicationSetup() {
|
||||
DI.global.addImport(
|
||||
DI.Module("Server") {
|
||||
bind<ApplicationDirs>() with singleton { applicationDirs }
|
||||
bind<IUpdater>() with singleton { Updater() }
|
||||
bind<JsonMapper>() with singleton { JavalinJackson() }
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug("Data Root directory is set to: ${applicationDirs.dataRoot}")
|
||||
|
||||
// Migrate Directories from old versions
|
||||
File("$ApplicationRootDir/manga-thumbnails").renameTo(applicationDirs.thumbnailsRoot)
|
||||
File("$ApplicationRootDir/manga-local").renameTo(applicationDirs.localMangaRoot)
|
||||
File("$ApplicationRootDir/manga").renameTo(applicationDirs.mangaDownloadsRoot)
|
||||
File("$ApplicationRootDir/anime-thumbnails").delete()
|
||||
|
||||
// make dirs we need
|
||||
listOf(
|
||||
applicationDirs.dataRoot,
|
||||
applicationDirs.extensionsRoot,
|
||||
applicationDirs.extensionsRoot + "/icon",
|
||||
applicationDirs.mangaThumbnailsRoot,
|
||||
applicationDirs.animeThumbnailsRoot,
|
||||
applicationDirs.mangaRoot,
|
||||
applicationDirs.thumbnailsRoot,
|
||||
applicationDirs.mangaDownloadsRoot,
|
||||
applicationDirs.localMangaRoot,
|
||||
).forEach {
|
||||
File(it).mkdirs()
|
||||
@@ -119,7 +130,7 @@ fun applicationSetup() {
|
||||
|
||||
databaseUp()
|
||||
|
||||
LocalSource.addDbRecords()
|
||||
LocalSource.register()
|
||||
|
||||
// create system tray
|
||||
if (serverConfig.systemTrayEnabled) {
|
||||
|
||||
@@ -23,9 +23,7 @@ object DBManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun databaseUp() {
|
||||
// must mention db object so the lazy block executes
|
||||
val db = DBManager.db
|
||||
fun databaseUp(db: Database = DBManager.db) {
|
||||
db.useNestedTransactions = true
|
||||
|
||||
val migrations = loadMigrationsFrom("suwayomi.tachidesk.server.database.migration", ServerConfig::class.java)
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ class M0004_AnimeTablesBatch1 : AddTableMigration() {
|
||||
override val id = long("id").entityId()
|
||||
val name = varchar("name", 128)
|
||||
val lang = varchar("lang", 10)
|
||||
val extension = reference("extension", suwayomi.tachidesk.anime.model.table.AnimeExtensionTable)
|
||||
val extension = reference("extension", AnimeExtensionTable())
|
||||
val partOfFactorySource = bool("part_of_factory_source").default(false)
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -8,7 +8,6 @@ package suwayomi.tachidesk.server.database.migration
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import de.neonew.exposed.migrations.helpers.AddTableMigration
|
||||
import eu.kanade.tachiyomi.animesource.model.SAnime
|
||||
import org.jetbrains.exposed.dao.id.IntIdTable
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
|
||||
@@ -25,7 +24,7 @@ class M0005_AnimeTablesBatch2 : AddTableMigration() {
|
||||
val genre = varchar("genre", 1024).nullable()
|
||||
|
||||
// val status = enumeration("status", MangaStatus::class).default(MangaStatus.UNKNOWN)
|
||||
val status = integer("status").default(SAnime.UNKNOWN)
|
||||
val status = integer("status").default(0)
|
||||
val thumbnail_url = varchar("thumbnail_url", 2048).nullable()
|
||||
|
||||
val inLibrary = bool("in_library").default(false)
|
||||
|
||||
+4
-2
@@ -10,10 +10,12 @@ package suwayomi.tachidesk.server.database.migration
|
||||
import de.neonew.exposed.migrations.helpers.AddTableMigration
|
||||
import org.jetbrains.exposed.dao.id.IntIdTable
|
||||
import org.jetbrains.exposed.sql.Table
|
||||
import suwayomi.tachidesk.anime.model.table.AnimeTable
|
||||
|
||||
@Suppress("ClassName", "unused")
|
||||
class M0006_AnimeTablesBatch3 : AddTableMigration() {
|
||||
// dummy table
|
||||
private class AnimeTable : IntIdTable()
|
||||
|
||||
private class EpisodeTable : IntIdTable() {
|
||||
val url = varchar("url", 2048)
|
||||
val name = varchar("name", 512)
|
||||
@@ -28,7 +30,7 @@ class M0006_AnimeTablesBatch3 : AddTableMigration() {
|
||||
// index is reserved by a function
|
||||
val animeIndex = integer("index")
|
||||
|
||||
val anime = reference("anime", AnimeTable)
|
||||
val anime = reference("anime", AnimeTable())
|
||||
}
|
||||
|
||||
override val tables: Array<Table>
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package suwayomi.tachidesk.server.database.migration
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import de.neonew.exposed.migrations.helpers.SQLMigration
|
||||
|
||||
@Suppress("ClassName", "unused")
|
||||
class M0019_RemoveAnime : SQLMigration() {
|
||||
val Anime = "ANIME"
|
||||
val AnimeExtension = "ANIMEEXTENSION"
|
||||
val AnimeSource = "ANIMESOURCE"
|
||||
val Episode = "EPISODE"
|
||||
|
||||
override val sql = """
|
||||
DROP TABLE $AnimeSource;
|
||||
DROP TABLE $AnimeExtension;
|
||||
DROP TABLE $Episode;
|
||||
DROP TABLE $Anime;
|
||||
""".trimIndent()
|
||||
}
|
||||
@@ -7,10 +7,13 @@ package suwayomi.tachidesk.server.util
|
||||
* 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.plugin.json.JavalinJackson
|
||||
import io.javalin.plugin.json.JsonMapper
|
||||
import mu.KotlinLogging
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request.Builder
|
||||
import org.kodein.di.DI
|
||||
import org.kodein.di.conf.global
|
||||
import org.kodein.di.instance
|
||||
import suwayomi.tachidesk.global.impl.AboutDataClass
|
||||
import suwayomi.tachidesk.server.serverConfig
|
||||
import suwayomi.tachidesk.server.util.Browser.openInBrowser
|
||||
@@ -30,6 +33,8 @@ object AppMutex {
|
||||
|
||||
private val appIP = if (serverConfig.ip == "0.0.0.0") "127.0.0.1" else serverConfig.ip
|
||||
|
||||
private val jsonMapper by DI.global.instance<JsonMapper>()
|
||||
|
||||
private fun checkAppMutex(): AppMutexState {
|
||||
val client = OkHttpClient.Builder()
|
||||
.connectTimeout(200, TimeUnit.MILLISECONDS)
|
||||
@@ -46,7 +51,7 @@ object AppMutex {
|
||||
}
|
||||
|
||||
return try {
|
||||
JavalinJackson().fromJsonString(response, AboutDataClass::class.java)
|
||||
jsonMapper.fromJsonString(response, AboutDataClass::class.java)
|
||||
AppMutexState.TachideskInstanceRunning
|
||||
} catch (e: IOException) {
|
||||
AppMutexState.OtherApplicationRunning
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
package suwayomi.tachidesk.server.util
|
||||
|
||||
import io.javalin.http.Context
|
||||
import io.javalin.plugin.openapi.dsl.DocumentedHandler
|
||||
import io.javalin.plugin.openapi.dsl.OpenApiDocumentation
|
||||
import io.javalin.plugin.openapi.dsl.documented
|
||||
import io.swagger.v3.oas.models.Operation
|
||||
|
||||
fun <T> getSimpleParamItem(ctx: Context, param: Param<T>): String? {
|
||||
return when (param) {
|
||||
is Param.FormParam -> ctx.formParam(param.key)
|
||||
is Param.PathParam -> ctx.pathParam(param.key)
|
||||
is Param.QueryParam -> ctx.queryParam(param.key)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> getParam(ctx: Context, param: Param<T>): T {
|
||||
val typedItem: Any? = when (param.clazz) {
|
||||
String::class.java -> getSimpleParamItem(ctx, param)
|
||||
Int::class.java -> getSimpleParamItem(ctx, param)?.toIntOrNull()
|
||||
Long::class.java -> getSimpleParamItem(ctx, param)?.toLongOrNull()
|
||||
Boolean::class.java -> getSimpleParamItem(ctx, param)?.toBoolean()
|
||||
Float::class.java -> getSimpleParamItem(ctx, param)?.toFloatOrNull()
|
||||
Double::class.java -> getSimpleParamItem(ctx, param)?.toDoubleOrNull()
|
||||
else -> {
|
||||
when (param) {
|
||||
is Param.FormParam -> ctx.formParamAsClass(param.key, param.clazz)
|
||||
is Param.PathParam -> ctx.pathParamAsClass(param.key, param.clazz)
|
||||
is Param.QueryParam -> ctx.queryParamAsClass(param.key, param.clazz)
|
||||
}.let {
|
||||
if (param.nullable) {
|
||||
it.allowNullable().get() ?: param.defaultValue
|
||||
} else {
|
||||
if (param.defaultValue != null) {
|
||||
it.getOrDefault(param.defaultValue!!)
|
||||
} else {
|
||||
it.get()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return if (param.nullable) {
|
||||
typedItem as T
|
||||
} else {
|
||||
typedItem!! as T
|
||||
}
|
||||
}
|
||||
|
||||
inline fun getDocumentation(
|
||||
documentWith: OpenApiDocumentation.() -> Unit,
|
||||
noinline withResults: ResultsBuilder.() -> Unit,
|
||||
vararg params: Param<*>
|
||||
): OpenApiDocumentation {
|
||||
return OpenApiDocumentation().apply(documentWith).apply {
|
||||
applyResults(withResults)
|
||||
params.forEach {
|
||||
when (it) {
|
||||
is Param.FormParam -> formParam(it.key, it.clazz, !it.nullable && it.defaultValue == null)
|
||||
is Param.PathParam -> pathParam(it.key, it.clazz)
|
||||
is Param.QueryParam -> queryParam(it.key, it.clazz,)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun OpenApiDocumentation.applyResults(withResults: ResultsBuilder.() -> Unit) {
|
||||
ResultsBuilder().apply(withResults).results.forEach {
|
||||
it.applyTo(this)
|
||||
}
|
||||
}
|
||||
|
||||
fun OpenApiDocumentation.withOperation(block: Operation.() -> Unit) {
|
||||
operation(block)
|
||||
}
|
||||
|
||||
inline fun <reified T> formParam(key: String, defaultValue: T? = null): Param.FormParam<T> {
|
||||
return Param.FormParam(key, T::class.java, defaultValue, null is T)
|
||||
}
|
||||
inline fun <reified T> queryParam(key: String, defaultValue: T? = null): Param.QueryParam<T> {
|
||||
return Param.QueryParam(key, T::class.java, defaultValue, null is T)
|
||||
}
|
||||
inline fun <reified T> pathParam(key: String): Param.PathParam<T> {
|
||||
return Param.PathParam(key, T::class.java, null, false)
|
||||
}
|
||||
|
||||
sealed class Param<T> {
|
||||
abstract val key: String
|
||||
abstract val clazz: Class<T>
|
||||
abstract val defaultValue: T?
|
||||
abstract val nullable: Boolean
|
||||
data class FormParam<T>(
|
||||
override val key: String,
|
||||
override val clazz: Class<T>,
|
||||
override val defaultValue: T?,
|
||||
override val nullable: Boolean
|
||||
) : Param<T>()
|
||||
data class QueryParam<T>(
|
||||
override val key: String,
|
||||
override val clazz: Class<T>,
|
||||
override val defaultValue: T?,
|
||||
override val nullable: Boolean
|
||||
) : Param<T>()
|
||||
data class PathParam<T>(
|
||||
override val key: String,
|
||||
override val clazz: Class<T>,
|
||||
override val defaultValue: T?,
|
||||
override val nullable: Boolean
|
||||
) : Param<T>()
|
||||
}
|
||||
|
||||
class ResultsBuilder {
|
||||
val results = mutableListOf<ResultType<*>>()
|
||||
|
||||
inline fun <reified T> json(status: String) {
|
||||
results += ResultType.MimeType(status, "application/json", T::class.java)
|
||||
}
|
||||
inline fun <reified T> plainText(status: String) {
|
||||
results += ResultType.MimeType(status, "text/plain", String::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ResultType <T> {
|
||||
abstract fun applyTo(documentation: OpenApiDocumentation)
|
||||
data class MimeType<T>(val status: String, val mime: String, private val clazz: Class<T>) : ResultType<T>() {
|
||||
override fun applyTo(documentation: OpenApiDocumentation) {
|
||||
documentation.result(status, clazz)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun handler(
|
||||
documentWith: OpenApiDocumentation.() -> Unit = {},
|
||||
noinline behaviorOf: (ctx: Context) -> Unit,
|
||||
noinline withResults: ResultsBuilder.() -> Unit
|
||||
): DocumentedHandler {
|
||||
return documented(
|
||||
documentation = getDocumentation(documentWith, withResults),
|
||||
handle = behaviorOf
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <reified P1> handler(
|
||||
param1: Param<P1>,
|
||||
documentWith: OpenApiDocumentation.() -> Unit,
|
||||
noinline behaviorOf: (ctx: Context, P1) -> Unit,
|
||||
noinline withResults: ResultsBuilder.() -> Unit
|
||||
): DocumentedHandler {
|
||||
return documented(
|
||||
documentation = getDocumentation(documentWith, withResults, param1),
|
||||
handle = {
|
||||
behaviorOf(
|
||||
it,
|
||||
getParam(it, param1)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <reified P1, reified P2> handler(
|
||||
param1: Param<P1>,
|
||||
param2: Param<P2>,
|
||||
documentWith: OpenApiDocumentation.() -> Unit = {},
|
||||
crossinline behaviorOf: (ctx: Context, P1, P2) -> Unit,
|
||||
noinline withResults: ResultsBuilder.() -> Unit
|
||||
): DocumentedHandler {
|
||||
return documented(
|
||||
documentation = getDocumentation(documentWith, withResults, param1, param2),
|
||||
handle = {
|
||||
behaviorOf(
|
||||
it,
|
||||
getParam(it, param1),
|
||||
getParam(it, param2)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <reified P1, reified P2, reified P3> handler(
|
||||
param1: Param<P1>,
|
||||
param2: Param<P2>,
|
||||
param3: Param<P3>,
|
||||
documentWith: OpenApiDocumentation.() -> Unit = {},
|
||||
crossinline behaviorOf: (ctx: Context, P1, P2, P3) -> Unit,
|
||||
noinline withResults: ResultsBuilder.() -> Unit
|
||||
): DocumentedHandler {
|
||||
return documented(
|
||||
documentation = getDocumentation(documentWith, withResults, param1, param2, param3),
|
||||
handle = {
|
||||
behaviorOf(
|
||||
it,
|
||||
getParam(it, param1),
|
||||
getParam(it, param2),
|
||||
getParam(it, param3),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <reified P1, reified P2, reified P3, reified P4> handler(
|
||||
param1: Param<P1>,
|
||||
param2: Param<P2>,
|
||||
param3: Param<P3>,
|
||||
param4: Param<P4>,
|
||||
documentWith: OpenApiDocumentation.() -> Unit = {},
|
||||
crossinline behaviorOf: (ctx: Context, P1, P2, P3, P4) -> Unit,
|
||||
noinline withResults: ResultsBuilder.() -> Unit
|
||||
): DocumentedHandler {
|
||||
return documented(
|
||||
documentation = getDocumentation(documentWith, withResults, param1, param2, param3, param4),
|
||||
handle = {
|
||||
behaviorOf(
|
||||
it,
|
||||
getParam(it, param1),
|
||||
getParam(it, param2),
|
||||
getParam(it, param3),
|
||||
getParam(it, param4),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <reified P1, reified P2, reified P3, reified P4, reified P5> handler(
|
||||
param1: Param<P1>,
|
||||
param2: Param<P2>,
|
||||
param3: Param<P3>,
|
||||
param4: Param<P4>,
|
||||
param5: Param<P5>,
|
||||
documentWith: OpenApiDocumentation.() -> Unit = {},
|
||||
crossinline behaviorOf: (ctx: Context, P1, P2, P3, P4, P5) -> Unit,
|
||||
noinline withResults: ResultsBuilder.() -> Unit
|
||||
): DocumentedHandler {
|
||||
return documented(
|
||||
documentation = getDocumentation(documentWith, withResults, param1, param2, param3, param4, param5),
|
||||
handle = {
|
||||
behaviorOf(
|
||||
it,
|
||||
getParam(it, param1),
|
||||
getParam(it, param2),
|
||||
getParam(it, param3),
|
||||
getParam(it, param4),
|
||||
getParam(it, param5),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <reified P1, reified P2, reified P3, reified P4, reified P5, reified P6> handler(
|
||||
param1: Param<P1>,
|
||||
param2: Param<P2>,
|
||||
param3: Param<P3>,
|
||||
param4: Param<P4>,
|
||||
param5: Param<P5>,
|
||||
param6: Param<P6>,
|
||||
documentWith: OpenApiDocumentation.() -> Unit = {},
|
||||
crossinline behaviorOf: (ctx: Context, P1, P2, P3, P4, P5, P6) -> Unit,
|
||||
noinline withResults: ResultsBuilder.() -> Unit
|
||||
): DocumentedHandler {
|
||||
return documented(
|
||||
documentation = getDocumentation(documentWith, withResults, param1, param2, param3, param4, param5, param6),
|
||||
handle = {
|
||||
behaviorOf(
|
||||
it,
|
||||
getParam(it, param1),
|
||||
getParam(it, param2),
|
||||
getParam(it, param3),
|
||||
getParam(it, param4),
|
||||
getParam(it, param5),
|
||||
getParam(it, param6),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <reified P1, reified P2, reified P3, reified P4, reified P5, reified P6, reified P7> handler(
|
||||
param1: Param<P1>,
|
||||
param2: Param<P2>,
|
||||
param3: Param<P3>,
|
||||
param4: Param<P4>,
|
||||
param5: Param<P5>,
|
||||
param6: Param<P6>,
|
||||
param7: Param<P7>,
|
||||
documentWith: OpenApiDocumentation.() -> Unit = {},
|
||||
crossinline behaviorOf: (ctx: Context, P1, P2, P3, P4, P5, P6, P7) -> Unit,
|
||||
noinline withResults: ResultsBuilder.() -> Unit
|
||||
): DocumentedHandler {
|
||||
return documented(
|
||||
documentation = getDocumentation(documentWith, withResults, param1, param2, param3, param4, param5, param6, param7),
|
||||
handle = {
|
||||
behaviorOf(
|
||||
it,
|
||||
getParam(it, param1),
|
||||
getParam(it, param2),
|
||||
getParam(it, param3),
|
||||
getParam(it, param4),
|
||||
getParam(it, param5),
|
||||
getParam(it, param6),
|
||||
getParam(it, param7),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <reified P1, reified P2, reified P3, reified P4, reified P5, reified P6, reified P7, reified P8> handler(
|
||||
param1: Param<P1>,
|
||||
param2: Param<P2>,
|
||||
param3: Param<P3>,
|
||||
param4: Param<P4>,
|
||||
param5: Param<P5>,
|
||||
param6: Param<P6>,
|
||||
param7: Param<P7>,
|
||||
param8: Param<P8>,
|
||||
documentWith: OpenApiDocumentation.() -> Unit = {},
|
||||
crossinline behaviorOf: (ctx: Context, P1, P2, P3, P4, P5, P6, P7, P8) -> Unit,
|
||||
noinline withResults: ResultsBuilder.() -> Unit
|
||||
): DocumentedHandler {
|
||||
return documented(
|
||||
documentation = getDocumentation(documentWith, withResults, param1, param2, param3, param4, param5, param6, param7, param8),
|
||||
handle = {
|
||||
behaviorOf(
|
||||
it,
|
||||
getParam(it, param1),
|
||||
getParam(it, param2),
|
||||
getParam(it, param3),
|
||||
getParam(it, param4),
|
||||
getParam(it, param5),
|
||||
getParam(it, param6),
|
||||
getParam(it, param7),
|
||||
getParam(it, param8),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <reified P1, reified P2, reified P3, reified P4, reified P5, reified P6, reified P7, reified P8, reified P9> handler(
|
||||
param1: Param<P1>,
|
||||
param2: Param<P2>,
|
||||
param3: Param<P3>,
|
||||
param4: Param<P4>,
|
||||
param5: Param<P5>,
|
||||
param6: Param<P6>,
|
||||
param7: Param<P7>,
|
||||
param8: Param<P8>,
|
||||
param9: Param<P9>,
|
||||
documentWith: OpenApiDocumentation.() -> Unit = {},
|
||||
crossinline behaviorOf: (ctx: Context, P1, P2, P3, P4, P5, P6, P7, P8, P9) -> Unit,
|
||||
noinline withResults: ResultsBuilder.() -> Unit
|
||||
): DocumentedHandler {
|
||||
return documented(
|
||||
documentation = getDocumentation(documentWith, withResults, param1, param2, param3, param4, param5, param6, param7, param8, param9),
|
||||
handle = {
|
||||
behaviorOf(
|
||||
it,
|
||||
getParam(it, param1),
|
||||
getParam(it, param2),
|
||||
getParam(it, param3),
|
||||
getParam(it, param4),
|
||||
getParam(it, param5),
|
||||
getParam(it, param6),
|
||||
getParam(it, param7),
|
||||
getParam(it, param8),
|
||||
getParam(it, param9),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <reified P1, reified P2, reified P3, reified P4, reified P5, reified P6, reified P7, reified P8, reified P9, reified P10> handler(
|
||||
param1: Param<P1>,
|
||||
param2: Param<P2>,
|
||||
param3: Param<P3>,
|
||||
param4: Param<P4>,
|
||||
param5: Param<P5>,
|
||||
param6: Param<P6>,
|
||||
param7: Param<P7>,
|
||||
param8: Param<P8>,
|
||||
param9: Param<P9>,
|
||||
param10: Param<P10>,
|
||||
documentWith: OpenApiDocumentation.() -> Unit = {},
|
||||
crossinline behaviorOf: (ctx: Context, P1, P2, P3, P4, P5, P6, P7, P8, P9, P10) -> Unit,
|
||||
noinline withResults: ResultsBuilder.() -> Unit
|
||||
): DocumentedHandler {
|
||||
return documented(
|
||||
documentation = getDocumentation(documentWith, withResults, param1, param2, param3, param4, param5, param6, param7, param8, param9, param10),
|
||||
handle = {
|
||||
behaviorOf(
|
||||
it,
|
||||
getParam(it, param1),
|
||||
getParam(it, param2),
|
||||
getParam(it, param3),
|
||||
getParam(it, param4),
|
||||
getParam(it, param5),
|
||||
getParam(it, param6),
|
||||
getParam(it, param7),
|
||||
getParam(it, param8),
|
||||
getParam(it, param9),
|
||||
getParam(it, param10),
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -7,10 +7,6 @@ server.socksProxyEnabled = false
|
||||
server.socksProxyHost = ""
|
||||
server.socksProxyPort = ""
|
||||
|
||||
# misc
|
||||
server.debugLogsEnabled = false
|
||||
server.systemTrayEnabled = true
|
||||
|
||||
# webUI
|
||||
server.webUIEnabled = true
|
||||
server.initialOpenInBrowserEnabled = true
|
||||
@@ -21,3 +17,7 @@ server.electronPath = ""
|
||||
server.basicAuthEnabled = false
|
||||
server.basicAuthUsername = ""
|
||||
server.basicAuthPassword = ""
|
||||
|
||||
# misc
|
||||
server.debugLogsEnabled = false
|
||||
server.systemTrayEnabled = true
|
||||
|
||||
+12
-9
@@ -1,4 +1,4 @@
|
||||
package suwayomi
|
||||
package masstest
|
||||
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
@@ -30,11 +30,14 @@ import suwayomi.tachidesk.manga.impl.util.lang.awaitSingle
|
||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.getCatalogueSource
|
||||
import suwayomi.tachidesk.manga.model.dataclass.ExtensionDataClass
|
||||
import suwayomi.tachidesk.server.applicationSetup
|
||||
import suwayomi.tachidesk.test.BASE_PATH
|
||||
import suwayomi.tachidesk.test.setLoggingEnabled
|
||||
import xyz.nulldev.ts.config.CONFIG_PREFIX
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class TestExtensions {
|
||||
class TestExtensionCompatibility {
|
||||
private val logger = KotlinLogging.logger {}
|
||||
private lateinit var extensions: List<ExtensionDataClass>
|
||||
private lateinit var sources: List<HttpSource>
|
||||
@@ -48,8 +51,8 @@ class TestExtensions {
|
||||
|
||||
@BeforeAll
|
||||
fun setup() {
|
||||
val dataRoot = File("tmp/TestDesk").absolutePath
|
||||
System.setProperty("suwayomi.tachidesk.server.rootDir", dataRoot)
|
||||
val dataRoot = File(BASE_PATH).absolutePath
|
||||
System.setProperty("$CONFIG_PREFIX.server.rootDir", dataRoot)
|
||||
applicationSetup()
|
||||
setLoggingEnabled(false)
|
||||
|
||||
@@ -72,7 +75,7 @@ class TestExtensions {
|
||||
sources = getSourceList().map { getCatalogueSource(it.id.toLong())!! as HttpSource }
|
||||
}
|
||||
setLoggingEnabled(true)
|
||||
File("tmp/TestDesk/sources.txt").writeText(sources.joinToString("\n") { "${it.name} - ${it.lang.uppercase()} - ${it.id}" })
|
||||
File("$BASE_PATH/sources.txt").writeText(sources.joinToString("\n") { "${it.name} - ${it.lang.uppercase()} - ${it.id}" })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -97,7 +100,7 @@ class TestExtensions {
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
File("tmp/TestDesk/failedToFetch.txt").writeText(
|
||||
File("$BASE_PATH/failedToFetch.txt").writeText(
|
||||
failedToFetch.joinToString("\n") { (source, exception) ->
|
||||
"${source.name} (${source.lang.uppercase()}, ${source.id}):" +
|
||||
" ${exception.message}"
|
||||
@@ -122,7 +125,7 @@ class TestExtensions {
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
File("tmp/TestDesk/MangaFailedToFetch.txt").writeText(
|
||||
File("$BASE_PATH/MangaFailedToFetch.txt").writeText(
|
||||
mangaFailedToFetch.joinToString("\n") { (source, manga, exception) ->
|
||||
"${source.name} (${source.lang}, ${source.id}):" +
|
||||
" ${manga.title} (${source.mangaDetailsRequest(manga).url}):" +
|
||||
@@ -157,7 +160,7 @@ class TestExtensions {
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
File("tmp/TestDesk/ChaptersFailedToFetch.txt").writeText(
|
||||
File("$BASE_PATH/ChaptersFailedToFetch.txt").writeText(
|
||||
chaptersFailedToFetch.joinToString("\n") { (source, manga, exception) ->
|
||||
"${source.name} (${source.lang}, ${source.id}):" +
|
||||
" ${manga.title} (${source.mangaDetailsRequest(manga).url}):" +
|
||||
@@ -182,7 +185,7 @@ class TestExtensions {
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
File("tmp/TestDesk/ChapterPageListFailedToFetch.txt").writeText(
|
||||
File("$BASE_PATH/ChapterPageListFailedToFetch.txt").writeText(
|
||||
chaptersPageListFailedToFetch.joinToString("\n") { (source, manga, exception) ->
|
||||
"${source.name} (${source.lang}, ${source.id}):" +
|
||||
" ${manga.first.title} (${source.mangaDetailsRequest(manga.first).url}):" +
|
||||
@@ -1,19 +0,0 @@
|
||||
package suwayomi
|
||||
|
||||
/*
|
||||
* 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 ch.qos.logback.classic.Level
|
||||
import mu.KotlinLogging
|
||||
import org.slf4j.Logger
|
||||
|
||||
fun setLoggingEnabled(enabled: Boolean = true) {
|
||||
val logger = (KotlinLogging.logger(Logger.ROOT_LOGGER_NAME).underlyingLogger as ch.qos.logback.classic.Logger)
|
||||
logger.level = if (enabled) {
|
||||
Level.DEBUG
|
||||
} else Level.ERROR
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package suwayomi.tachidesk.manga.controller
|
||||
|
||||
/*
|
||||
* 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.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import suwayomi.tachidesk.manga.impl.Category
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryTable
|
||||
import suwayomi.tachidesk.test.ApplicationTest
|
||||
import suwayomi.tachidesk.test.clearTables
|
||||
|
||||
class CategoryControllerTest : ApplicationTest() {
|
||||
@Test
|
||||
fun categoryReorder() {
|
||||
Category.createCategory("foo")
|
||||
Category.createCategory("bar")
|
||||
val cats = Category.getCategoryList()
|
||||
val foo = cats.asSequence().filter { it.name == "foo" }.first()
|
||||
val bar = cats.asSequence().filter { it.name == "bar" }.first()
|
||||
assertEquals(1, foo.order)
|
||||
assertEquals(2, bar.order)
|
||||
Category.reorderCategory(1, 2)
|
||||
val catsReordered = Category.getCategoryList()
|
||||
val fooReordered = catsReordered.asSequence().filter { it.name == "foo" }.first()
|
||||
val barReordered = catsReordered.asSequence().filter { it.name == "bar" }.first()
|
||||
assertEquals(2, fooReordered.order)
|
||||
assertEquals(1, barReordered.order)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
internal fun tearDown() {
|
||||
clearTables(
|
||||
CategoryTable
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package suwayomi.tachidesk.manga.controller
|
||||
|
||||
import io.javalin.http.Context
|
||||
import io.javalin.http.HttpCode
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.jetbrains.exposed.sql.insertAndGetId
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
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.update.IUpdater
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import suwayomi.tachidesk.test.ApplicationTest
|
||||
import suwayomi.tachidesk.test.clearTables
|
||||
|
||||
internal class UpdateControllerTest : ApplicationTest() {
|
||||
private val ctx = mockk<Context>(relaxed = true)
|
||||
|
||||
@Test
|
||||
fun `POST non existent Category Id should give error`() {
|
||||
every { ctx.formParam("category") } returns "1"
|
||||
UpdateController.categoryUpdate(ctx)
|
||||
verify { ctx.status(HttpCode.BAD_REQUEST) }
|
||||
val updater by DI.global.instance<IUpdater>()
|
||||
assertEquals(0, updater.getStatus().value.numberOfJobs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `POST existent Category Id should give success`() {
|
||||
Category.createCategory("foo")
|
||||
createLibraryManga("bar")
|
||||
CategoryManga.addMangaToCategory(1, 1)
|
||||
every { ctx.formParam("category") } returns "1"
|
||||
UpdateController.categoryUpdate(ctx)
|
||||
verify { ctx.status(HttpCode.OK) }
|
||||
val updater by DI.global.instance<IUpdater>()
|
||||
assertEquals(1, updater.getStatus().value.numberOfJobs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `POST null or empty category should update library`() {
|
||||
val fooCatId = Category.createCategory("foo")
|
||||
val fooMangaId = createLibraryManga("foo")
|
||||
CategoryManga.addMangaToCategory(fooMangaId, fooCatId)
|
||||
val barCatId = Category.createCategory("bar")
|
||||
val barMangaId = createLibraryManga("bar")
|
||||
CategoryManga.addMangaToCategory(barMangaId, barCatId)
|
||||
createLibraryManga("mangaInDefault")
|
||||
every { ctx.formParam("category") } returns null
|
||||
UpdateController.categoryUpdate(ctx)
|
||||
verify { ctx.status(HttpCode.OK) }
|
||||
val updater by DI.global.instance<IUpdater>()
|
||||
assertEquals(3, updater.getStatus().value.numberOfJobs)
|
||||
}
|
||||
|
||||
private fun createLibraryManga(
|
||||
_title: String
|
||||
): Int {
|
||||
return transaction {
|
||||
MangaTable.insertAndGetId {
|
||||
it[title] = _title
|
||||
it[url] = _title
|
||||
it[sourceReference] = 1
|
||||
it[defaultCategory] = true
|
||||
it[inLibrary] = true
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
internal fun tearDown() {
|
||||
clearTables(
|
||||
CategoryMangaTable,
|
||||
MangaTable,
|
||||
CategoryTable
|
||||
)
|
||||
val updater by DI.global.instance<IUpdater>()
|
||||
runBlocking { updater.reset() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package suwayomi.tachidesk.manga.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 org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import suwayomi.tachidesk.manga.impl.Category.DEFAULT_CATEGORY_ID
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryMangaTable
|
||||
import suwayomi.tachidesk.manga.model.table.CategoryTable
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import suwayomi.tachidesk.test.ApplicationTest
|
||||
import suwayomi.tachidesk.test.clearTables
|
||||
import suwayomi.tachidesk.test.createChapters
|
||||
import suwayomi.tachidesk.test.createLibraryManga
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class CategoryMangaTest : ApplicationTest() {
|
||||
@Test
|
||||
fun getCategoryMangaList() {
|
||||
val emptyCats = CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID).size
|
||||
assertEquals(0, emptyCats, "Default category should be empty at start")
|
||||
val mangaId = createLibraryManga("Psyren")
|
||||
createChapters(mangaId, 10, true)
|
||||
assertEquals(1, CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID).size, "Default category should have one member")
|
||||
assertEquals(
|
||||
0, CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID)[0].unreadCount,
|
||||
"Manga should not have any unread chapters"
|
||||
)
|
||||
createChapters(mangaId, 10, false)
|
||||
assertEquals(
|
||||
10, CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID)[0].unreadCount,
|
||||
"Manga should have unread chapters"
|
||||
)
|
||||
|
||||
val categoryId = Category.createCategory("Old")
|
||||
assertEquals(
|
||||
0,
|
||||
CategoryManga.getCategoryMangaList(categoryId).size,
|
||||
"Newly created category shouldn't have any Mangas"
|
||||
)
|
||||
CategoryManga.addMangaToCategory(mangaId, categoryId)
|
||||
assertEquals(
|
||||
1, CategoryManga.getCategoryMangaList(categoryId).size,
|
||||
"Manga should been moved"
|
||||
)
|
||||
assertEquals(
|
||||
10, CategoryManga.getCategoryMangaList(categoryId)[0].unreadCount,
|
||||
"Manga should keep it's unread count in moved category"
|
||||
)
|
||||
assertEquals(
|
||||
0, CategoryManga.getCategoryMangaList(DEFAULT_CATEGORY_ID).size,
|
||||
"Manga shouldn't be member of default category after moving"
|
||||
)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
internal fun tearDown() {
|
||||
clearTables(
|
||||
ChapterTable,
|
||||
CategoryMangaTable,
|
||||
MangaTable,
|
||||
CategoryTable
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package suwayomi.tachidesk.manga.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 org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import suwayomi.tachidesk.manga.model.table.MangaMetaTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
import suwayomi.tachidesk.test.ApplicationTest
|
||||
import suwayomi.tachidesk.test.clearTables
|
||||
import suwayomi.tachidesk.test.createLibraryManga
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class MangaTest : ApplicationTest() {
|
||||
@Test
|
||||
fun getMangaMeta() {
|
||||
val metaManga = createLibraryManga("META_TEST")
|
||||
val emptyMeta = Manga.getMangaMetaMap(metaManga).size
|
||||
assertEquals(0, emptyMeta, "Default Manga meta should be empty at start")
|
||||
|
||||
Manga.modifyMangaMeta(metaManga, "test", "value")
|
||||
assertEquals(1, Manga.getMangaMetaMap(metaManga).size, "Manga meta should have one member")
|
||||
assertEquals("value", Manga.getMangaMetaMap(metaManga)["test"], "Manga meta use the value 'value' for key 'test'")
|
||||
|
||||
Manga.modifyMangaMeta(metaManga, "test", "newValue")
|
||||
assertEquals(
|
||||
1,
|
||||
Manga.getMangaMetaMap(metaManga).size,
|
||||
"Manga meta should still only have one pair"
|
||||
)
|
||||
assertEquals(
|
||||
"newValue", Manga.getMangaMetaMap(metaManga)["test"],
|
||||
"Manga meta with key 'test' should use the value `newValue`"
|
||||
)
|
||||
|
||||
Manga.modifyMangaMeta(metaManga, "test2", "value2")
|
||||
assertEquals(
|
||||
2, Manga.getMangaMetaMap(metaManga).size,
|
||||
"Manga Meta should have an additional pair"
|
||||
)
|
||||
assertEquals(
|
||||
"value2", Manga.getMangaMetaMap(metaManga)["test2"],
|
||||
"Manga Meta for key 'test2' should be 'value2'"
|
||||
)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
internal fun tearDown() {
|
||||
clearTables(
|
||||
MangaMetaTable,
|
||||
MangaTable
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package suwayomi.tachidesk.manga.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 org.junit.jupiter.api.Test
|
||||
import suwayomi.tachidesk.manga.impl.Page.getPageName
|
||||
import suwayomi.tachidesk.test.ApplicationTest
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class PageTest : ApplicationTest() {
|
||||
|
||||
@Test
|
||||
fun testGetPageName() {
|
||||
val tests = listOf(0, 1, 2, 100)
|
||||
|
||||
val testResults = tests.map {
|
||||
getPageName(it)
|
||||
}
|
||||
|
||||
assertEquals(testResults[0], "001")
|
||||
assertEquals(testResults[1], "002")
|
||||
assertEquals(testResults[2], "003")
|
||||
assertEquals(testResults[3], "101")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package suwayomi.tachidesk.manga.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.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import io.javalin.plugin.json.JavalinJackson
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.AfterAll
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import rx.Observable
|
||||
import suwayomi.tachidesk.manga.impl.Search.FilterChange
|
||||
import suwayomi.tachidesk.manga.impl.Search.FilterObject
|
||||
import suwayomi.tachidesk.manga.impl.Search.SerializableGroup
|
||||
import suwayomi.tachidesk.manga.impl.Search.getFilterList
|
||||
import suwayomi.tachidesk.manga.impl.Search.setFilter
|
||||
import suwayomi.tachidesk.manga.impl.Search.sourceSearch
|
||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.registerCatalogueSource
|
||||
import suwayomi.tachidesk.manga.impl.util.source.GetCatalogueSource.unregisterCatalogueSource
|
||||
import suwayomi.tachidesk.manga.impl.util.source.StubSource
|
||||
import suwayomi.tachidesk.test.ApplicationTest
|
||||
import suwayomi.tachidesk.test.createSMangas
|
||||
import kotlin.reflect.KClass
|
||||
import kotlin.reflect.full.primaryConstructor
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class SearchTest : ApplicationTest() {
|
||||
class FakeSearchableSource(id: Long) : StubSource(id) {
|
||||
var mangas: List<SManga> = emptyList()
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return Observable.just(MangasPage(mangas, false))
|
||||
}
|
||||
}
|
||||
|
||||
private val sourceId = 1L
|
||||
private val source = FakeSearchableSource(sourceId)
|
||||
private val mangasCount = 10
|
||||
|
||||
@BeforeAll
|
||||
fun setup() {
|
||||
registerCatalogueSource(sourceId to source)
|
||||
|
||||
this.source.mangas = createSMangas(mangasCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchWorks() {
|
||||
val searchResults = runBlocking {
|
||||
sourceSearch(sourceId, "all the mangas", 1)
|
||||
}
|
||||
|
||||
assertEquals(mangasCount, searchResults.mangaList.size, "should return all the mangas")
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
fun teardown() {
|
||||
unregisterCatalogueSource(this.sourceId)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
class FilterListTest : ApplicationTest() {
|
||||
open class EmptyFilterListSource(id: Long) : StubSource(id) {
|
||||
open var mFilterList = FilterList()
|
||||
|
||||
override fun getFilterList(): FilterList {
|
||||
return mFilterList
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty FilterList returns empty List`() {
|
||||
val source = registerSource(EmptyFilterListSource::class)
|
||||
source.mFilterList = FilterList()
|
||||
|
||||
val filterList = getFilterList(source.id, false)
|
||||
|
||||
assertEquals(
|
||||
0, filterList.size
|
||||
)
|
||||
}
|
||||
|
||||
class FilterListSource(id: Long) : EmptyFilterListSource(id) {
|
||||
class SelectFilter(name: String, values: Array<String>) : Filter.Select<String>(name, values)
|
||||
class TextFilter(name: String) : Filter.Text(name)
|
||||
class TestCheckBox(name: String) : Filter.CheckBox(name, false)
|
||||
class TriState(name: String, state: Int) : Filter.TriState(name, state)
|
||||
class Group(name: String, state: List<TestCheckBox>) : Filter.Group<TestCheckBox>(name, state)
|
||||
class Sort(name: String, values: Array<String>, state: Selection) : Filter.Sort(name, values, state)
|
||||
|
||||
override var mFilterList = FilterList(
|
||||
Filter.Header("This is a header"),
|
||||
Filter.Separator(),
|
||||
SelectFilter("Select one of these:", arrayOf("this", "that", "none of them")),
|
||||
TextFilter("text filter"),
|
||||
TestCheckBox("check this or else!"),
|
||||
TriState("wanna hook up?", Filter.TriState.STATE_IGNORE),
|
||||
Group(
|
||||
"my Todo",
|
||||
listOf(
|
||||
TestCheckBox("Write Tests"),
|
||||
TestCheckBox("Write More Tests"),
|
||||
TestCheckBox("Write Even More Tests"),
|
||||
)
|
||||
),
|
||||
Sort(
|
||||
"Sort",
|
||||
arrayOf("Alphabetic", "Date published", "Rating"),
|
||||
Filter.Sort.Selection(2, false)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun convertsEveryTypeCorrectly() {
|
||||
val source = registerSource(FilterListSource::class)
|
||||
val filterList = getFilterList(source.id, false)
|
||||
|
||||
assertEquals(
|
||||
FilterObject("Header", source.mFilterList[0]),
|
||||
filterList[0]
|
||||
)
|
||||
assertEquals(
|
||||
FilterObject("Separator", source.mFilterList[1]),
|
||||
filterList[1]
|
||||
)
|
||||
assertEquals(
|
||||
FilterObject("Select", source.mFilterList[2]),
|
||||
filterList[2]
|
||||
)
|
||||
assertEquals(
|
||||
FilterObject("Text", source.mFilterList[3]),
|
||||
filterList[3]
|
||||
)
|
||||
assertEquals(
|
||||
FilterObject("CheckBox", source.mFilterList[4]),
|
||||
filterList[4]
|
||||
)
|
||||
assertEquals(
|
||||
FilterObject("TriState", source.mFilterList[5]),
|
||||
filterList[5]
|
||||
)
|
||||
assertEquals(
|
||||
filterList[6],
|
||||
FilterObject(
|
||||
"Group",
|
||||
SerializableGroup(
|
||||
source.mFilterList[6].name,
|
||||
listOf(
|
||||
FilterObject("CheckBox", (source.mFilterList[6].state as List<Filter<*>>)[0]),
|
||||
FilterObject("CheckBox", (source.mFilterList[6].state as List<Filter<*>>)[1]),
|
||||
FilterObject("CheckBox", (source.mFilterList[6].state as List<Filter<*>>)[2]),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
FilterObject("Sort", source.mFilterList[7]),
|
||||
filterList[7]
|
||||
)
|
||||
|
||||
// make sure that we can convert this to json
|
||||
JavalinJackson().toJsonString(filterList)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Header and Separator should not change`() {
|
||||
val source = registerSource(FilterListSource::class)
|
||||
|
||||
setFilter(
|
||||
source.id,
|
||||
FilterChange(0, "change!")
|
||||
)
|
||||
|
||||
setFilter(
|
||||
source.id,
|
||||
FilterChange(1, "change!")
|
||||
)
|
||||
|
||||
val filterList = getFilterList(source.id, false)
|
||||
|
||||
assertEquals(
|
||||
filterList[0].filter.state,
|
||||
0
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
filterList[1].filter.state,
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Select changes are Int`() {
|
||||
val source = registerSource(FilterListSource::class)
|
||||
|
||||
setFilter(
|
||||
source.id,
|
||||
FilterChange(2, "1")
|
||||
)
|
||||
|
||||
val filterList = getFilterList(source.id, false)
|
||||
|
||||
assertEquals(
|
||||
filterList[2].filter.state,
|
||||
1
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Text changes are String`() {
|
||||
val source = registerSource(FilterListSource::class)
|
||||
|
||||
setFilter(
|
||||
source.id,
|
||||
FilterChange(3, "I'm a changed man!")
|
||||
)
|
||||
|
||||
val filterList = getFilterList(source.id, false)
|
||||
|
||||
assertEquals(
|
||||
filterList[3].filter.state,
|
||||
"I'm a changed man!"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CheckBox changes are Boolean`() {
|
||||
val source = registerSource(FilterListSource::class)
|
||||
|
||||
setFilter(
|
||||
source.id,
|
||||
FilterChange(4, "true")
|
||||
)
|
||||
|
||||
val filterList = getFilterList(source.id, false)
|
||||
|
||||
assertEquals(
|
||||
filterList[4].filter.state,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TriState changes are Int`() {
|
||||
val source = registerSource(FilterListSource::class)
|
||||
|
||||
setFilter(
|
||||
source.id,
|
||||
FilterChange(5, "1")
|
||||
)
|
||||
|
||||
val filterList = getFilterList(source.id, false)
|
||||
|
||||
assertEquals(
|
||||
filterList[5].filter.state,
|
||||
Filter.TriState.STATE_INCLUDE
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Group changes are Filters`() {
|
||||
val source = registerSource(FilterListSource::class)
|
||||
|
||||
setFilter(
|
||||
source.id,
|
||||
FilterChange(6, """{"position":0,"state":"true"}""")
|
||||
)
|
||||
|
||||
val filterList = getFilterList(source.id, false)
|
||||
|
||||
assertEquals(
|
||||
(filterList[6].filter.state as List<FilterObject>)[0].filter.state,
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Sort changes are Filter,Sort,Selection`() {
|
||||
val source = registerSource(FilterListSource::class)
|
||||
|
||||
setFilter(
|
||||
source.id,
|
||||
FilterChange(7, """{"index":1,"ascending":"true"}""")
|
||||
)
|
||||
|
||||
val filterList = getFilterList(source.id, false)
|
||||
|
||||
assertEquals(
|
||||
filterList[7].filter.state,
|
||||
Filter.Sort.Selection(1, true)
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var sourceCount = 0L
|
||||
|
||||
private fun registerSource(sourceClass: KClass<*>): EmptyFilterListSource {
|
||||
return synchronized(sourceCount) {
|
||||
val source = sourceClass.primaryConstructor!!.call(sourceCount) as EmptyFilterListSource
|
||||
registerCatalogueSource(sourceCount to source)
|
||||
sourceCount++
|
||||
source
|
||||
}
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
fun teardown() {
|
||||
(0 until sourceCount).forEach { unregisterCatalogueSource(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package suwayomi.tachidesk.manga.impl.update
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import suwayomi.tachidesk.manga.model.dataclass.MangaDataClass
|
||||
|
||||
class TestUpdater : IUpdater {
|
||||
private val updateQueue = ArrayList<UpdateJob>()
|
||||
private var isRunning = false
|
||||
|
||||
override fun addMangaToQueue(manga: MangaDataClass) {
|
||||
updateQueue.add(UpdateJob(manga))
|
||||
isRunning = true
|
||||
}
|
||||
|
||||
override fun getStatus(): StateFlow<UpdateStatus> {
|
||||
return MutableStateFlow(UpdateStatus(updateQueue, isRunning))
|
||||
}
|
||||
|
||||
override suspend fun reset() {
|
||||
updateQueue.clear()
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package suwayomi.tachidesk.manga.model
|
||||
|
||||
/*
|
||||
* 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.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import suwayomi.tachidesk.manga.model.dataclass.PaginatedList
|
||||
import suwayomi.tachidesk.manga.model.dataclass.PaginationFactor
|
||||
import suwayomi.tachidesk.manga.model.dataclass.paginatedFrom
|
||||
import suwayomi.tachidesk.test.ApplicationTest
|
||||
|
||||
class PaginatedListTest : ApplicationTest() {
|
||||
@Test
|
||||
fun `empty list`() {
|
||||
val paginated = paginatedFrom(0) { listIndicesOf(0, 0) }
|
||||
|
||||
assertEquals(
|
||||
PaginatedList(emptyList<Int>(), false),
|
||||
paginated
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `size smaller than PaginationFactor`() {
|
||||
val paginated = paginatedFrom(0) { listIndicesOf(0, PaginationFactor - 1) }
|
||||
|
||||
assertEquals(
|
||||
PaginatedList(listIndicesOf(0, PaginationFactor - 1), false),
|
||||
paginated,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one less than two times PaginationFactor`() {
|
||||
val masterLister = { listIndicesOf(0, PaginationFactor * 2 - 1) }
|
||||
|
||||
val firstPage = paginatedFrom(0, lister = masterLister)
|
||||
|
||||
assertEquals(
|
||||
PaginatedList(listIndicesOf(0, PaginationFactor), true),
|
||||
firstPage,
|
||||
)
|
||||
|
||||
val secondPage = paginatedFrom(1, lister = masterLister)
|
||||
|
||||
assertEquals(
|
||||
PaginatedList(listIndicesOf(PaginationFactor, PaginationFactor * 2 - 1), false),
|
||||
secondPage,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two times PaginationFactor`() {
|
||||
val masterLister = { listIndicesOf(0, PaginationFactor * 2) }
|
||||
|
||||
val firstPage = paginatedFrom(0, lister = masterLister)
|
||||
|
||||
assertEquals(
|
||||
PaginatedList(listIndicesOf(0, PaginationFactor), true),
|
||||
firstPage,
|
||||
)
|
||||
|
||||
val secondPage = paginatedFrom(1, lister = masterLister)
|
||||
|
||||
assertEquals(
|
||||
PaginatedList(listIndicesOf(PaginationFactor, PaginationFactor * 2), false),
|
||||
secondPage,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one more than two times PaginationFactor`() {
|
||||
val masterLister = { listIndicesOf(0, PaginationFactor * 2 + 1) }
|
||||
|
||||
val firstPage = paginatedFrom(0, lister = masterLister)
|
||||
|
||||
assertEquals(
|
||||
PaginatedList(listIndicesOf(0, PaginationFactor), true),
|
||||
firstPage,
|
||||
)
|
||||
|
||||
val secondPage = paginatedFrom(1, lister = masterLister)
|
||||
|
||||
assertEquals(
|
||||
PaginatedList(listIndicesOf(PaginationFactor, PaginationFactor * 2), true),
|
||||
secondPage,
|
||||
)
|
||||
|
||||
val thirdPage = paginatedFrom(2, lister = masterLister)
|
||||
|
||||
assertEquals(
|
||||
PaginatedList(listIndicesOf(PaginationFactor * 2, PaginationFactor * 2 + 1), false),
|
||||
thirdPage,
|
||||
)
|
||||
}
|
||||
|
||||
private fun listIndicesOf(first: Int, last: Int): List<Int> {
|
||||
return (first until last).toList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package suwayomi.tachidesk.test
|
||||
|
||||
/*
|
||||
* 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.App
|
||||
import eu.kanade.tachiyomi.source.local.LocalSource
|
||||
import io.javalin.plugin.json.JavalinJackson
|
||||
import io.javalin.plugin.json.JsonMapper
|
||||
import mu.KotlinLogging
|
||||
import org.jetbrains.exposed.sql.Database
|
||||
import org.junit.jupiter.api.BeforeAll
|
||||
import org.kodein.di.DI
|
||||
import org.kodein.di.bind
|
||||
import org.kodein.di.conf.global
|
||||
import org.kodein.di.singleton
|
||||
import suwayomi.tachidesk.manga.impl.update.IUpdater
|
||||
import suwayomi.tachidesk.manga.impl.update.TestUpdater
|
||||
import suwayomi.tachidesk.server.ApplicationDirs
|
||||
import suwayomi.tachidesk.server.JavalinSetup
|
||||
import suwayomi.tachidesk.server.ServerConfig
|
||||
import suwayomi.tachidesk.server.androidCompat
|
||||
import suwayomi.tachidesk.server.database.databaseUp
|
||||
import suwayomi.tachidesk.server.serverConfig
|
||||
import suwayomi.tachidesk.server.systemTrayInstance
|
||||
import suwayomi.tachidesk.server.util.AppMutex
|
||||
import xyz.nulldev.androidcompat.AndroidCompatInitializer
|
||||
import xyz.nulldev.ts.config.CONFIG_PREFIX
|
||||
import xyz.nulldev.ts.config.ConfigKodeinModule
|
||||
import xyz.nulldev.ts.config.GlobalConfigManager
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
open class ApplicationTest {
|
||||
companion object {
|
||||
@BeforeAll
|
||||
@JvmStatic
|
||||
fun beforeAll() {
|
||||
if (!initializedTheApp) {
|
||||
val dataRoot = File(BASE_PATH).absolutePath
|
||||
System.setProperty("$CONFIG_PREFIX.server.rootDir", dataRoot)
|
||||
|
||||
testingSetup()
|
||||
|
||||
databaseSetup()
|
||||
|
||||
initializedTheApp = true
|
||||
}
|
||||
}
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
private var initializedTheApp = false
|
||||
|
||||
fun testingSetup() {
|
||||
// Application dirs
|
||||
val applicationDirs = ApplicationDirs()
|
||||
|
||||
DI.global.addImport(
|
||||
DI.Module("Server") {
|
||||
bind<ApplicationDirs>() with singleton { applicationDirs }
|
||||
bind<JsonMapper>() with singleton { JavalinJackson() }
|
||||
bind<IUpdater>() with singleton { TestUpdater() }
|
||||
}
|
||||
)
|
||||
|
||||
logger.debug("Data Root directory is set to: ${applicationDirs.dataRoot}")
|
||||
|
||||
// make dirs we need
|
||||
listOf(
|
||||
applicationDirs.dataRoot,
|
||||
applicationDirs.extensionsRoot,
|
||||
applicationDirs.extensionsRoot + "/icon",
|
||||
applicationDirs.thumbnailsRoot,
|
||||
applicationDirs.mangaDownloadsRoot,
|
||||
applicationDirs.localMangaRoot,
|
||||
).forEach {
|
||||
File(it).mkdirs()
|
||||
}
|
||||
|
||||
// register Tachidesk's config which is dubbed "ServerConfig"
|
||||
GlobalConfigManager.registerModule(
|
||||
ServerConfig.register(GlobalConfigManager.config)
|
||||
)
|
||||
|
||||
// Make sure only one instance of the app is running
|
||||
AppMutex.handleAppMutex()
|
||||
|
||||
// Load config API
|
||||
DI.global.addImport(ConfigKodeinModule().create())
|
||||
// Load Android compatibility dependencies
|
||||
AndroidCompatInitializer().init()
|
||||
// start app
|
||||
androidCompat.startApp(App())
|
||||
|
||||
// create conf file if doesn't exist
|
||||
try {
|
||||
val dataConfFile = File("${applicationDirs.dataRoot}/server.conf")
|
||||
if (!dataConfFile.exists()) {
|
||||
JavalinSetup::class.java.getResourceAsStream("/server-reference.conf").use { input ->
|
||||
dataConfFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("Exception while creating initial server.conf", e)
|
||||
}
|
||||
|
||||
// copy local source icon
|
||||
try {
|
||||
val localSourceIconFile = File("${applicationDirs.extensionsRoot}/icon/localSource.png")
|
||||
if (!localSourceIconFile.exists()) {
|
||||
JavalinSetup::class.java.getResourceAsStream("/icon/localSource.png").use { input ->
|
||||
localSourceIconFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.error("Exception while copying Local source's icon", e)
|
||||
}
|
||||
|
||||
// create system tray
|
||||
if (serverConfig.systemTrayEnabled) {
|
||||
try {
|
||||
systemTrayInstance
|
||||
} catch (e: Throwable) { // cover both java.lang.Exception and java.lang.Error
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
// Disable jetty's logging
|
||||
System.setProperty("org.eclipse.jetty.util.log.announce", "false")
|
||||
System.setProperty("org.eclipse.jetty.util.log.class", "org.eclipse.jetty.util.log.StdErrLog")
|
||||
System.setProperty("org.eclipse.jetty.LEVEL", "OFF")
|
||||
|
||||
// socks proxy settings
|
||||
if (serverConfig.socksProxyEnabled) {
|
||||
System.getProperties()["socksProxyHost"] = serverConfig.socksProxyHost
|
||||
System.getProperties()["socksProxyPort"] = serverConfig.socksProxyPort
|
||||
logger.info("Socks Proxy is enabled to ${serverConfig.socksProxyHost}:${serverConfig.socksProxyPort}")
|
||||
}
|
||||
}
|
||||
|
||||
fun databaseSetup() {
|
||||
// fixes #119 , ref: https://github.com/Suwayomi/Tachidesk-Server/issues/119#issuecomment-894681292 , source Id calculation depends on String.lowercase()
|
||||
Locale.setDefault(Locale.ENGLISH)
|
||||
|
||||
// in-memory database, don't discard database between connections/transactions
|
||||
val db = Database.connect("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;", "org.h2.Driver")
|
||||
|
||||
databaseUp(db)
|
||||
|
||||
LocalSource.register()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package suwayomi.tachidesk.test
|
||||
|
||||
/*
|
||||
* 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 ch.qos.logback.classic.Level
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import mu.KotlinLogging
|
||||
import org.jetbrains.exposed.dao.id.IdTable
|
||||
import org.jetbrains.exposed.sql.batchInsert
|
||||
import org.jetbrains.exposed.sql.deleteAll
|
||||
import org.jetbrains.exposed.sql.insertAndGetId
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.slf4j.Logger
|
||||
import suwayomi.tachidesk.manga.model.table.ChapterTable
|
||||
import suwayomi.tachidesk.manga.model.table.MangaTable
|
||||
|
||||
fun setLoggingEnabled(enabled: Boolean = true) {
|
||||
val logger = (KotlinLogging.logger(Logger.ROOT_LOGGER_NAME).underlyingLogger as ch.qos.logback.classic.Logger)
|
||||
logger.level = if (enabled) {
|
||||
Level.DEBUG
|
||||
} else Level.ERROR
|
||||
}
|
||||
|
||||
const val BASE_PATH = "build/tmp/TestDesk"
|
||||
|
||||
fun createLibraryManga(
|
||||
_title: String
|
||||
): Int {
|
||||
return transaction {
|
||||
MangaTable.insertAndGetId {
|
||||
it[title] = _title
|
||||
it[url] = _title
|
||||
it[sourceReference] = 1
|
||||
it[defaultCategory] = true
|
||||
it[inLibrary] = true
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
fun createSMangas(
|
||||
count: Int
|
||||
): List<SManga> {
|
||||
return (0 until count).map {
|
||||
SManga.create().apply {
|
||||
title = "Manga $it"
|
||||
url = "https://$title"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createChapters(
|
||||
mangaId: Int,
|
||||
amount: Int,
|
||||
read: Boolean
|
||||
) {
|
||||
val list = listOf((0 until amount)).flatten().map { 1 }
|
||||
transaction {
|
||||
ChapterTable
|
||||
.batchInsert(list) {
|
||||
this[ChapterTable.url] = "$it"
|
||||
this[ChapterTable.name] = "$it"
|
||||
this[ChapterTable.sourceOrder] = it
|
||||
this[ChapterTable.isRead] = read
|
||||
this[ChapterTable.manga] = mangaId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearTables(vararg tables: IdTable<*>) {
|
||||
transaction {
|
||||
for (table in tables) {
|
||||
table.deleteAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user