mirror of
https://github.com/mihonapp/mihon.git
synced 2025-06-30 04:57:50 +02:00
Compare commits
126 Commits
Author | SHA1 | Date | |
---|---|---|---|
82141cec6e | |||
218313428f | |||
44b47b49bc | |||
2f69317f5d | |||
e1eff7b744 | |||
3aa12281c3 | |||
72920130c0 | |||
0fd00331e1 | |||
7a4763ee68 | |||
647a78b791 | |||
82faa91ce3 | |||
ad664dfb9f | |||
005ac9e732 | |||
d8e3fe542d | |||
c40e4f6c5a | |||
9bb3195bca | |||
eee0bc4985 | |||
a24d670f54 | |||
51e049ab78 | |||
01e37dfab8 | |||
74087edebb | |||
db58c9b77f | |||
c4dad1c20b | |||
cae04656b9 | |||
aa57b1bc77 | |||
491d476cac | |||
f0053a2f78 | |||
10e7a3b35b | |||
4147fd6b19 | |||
2bb903088e | |||
c90f985fcc | |||
2ebaacfc89 | |||
c339bd49d0 | |||
c349fb0e37 | |||
bc825bdefa | |||
ed49ce8e1d | |||
ad2ecd538d | |||
ff8e3f0af4 | |||
698e17178a | |||
ebeee70931 | |||
b8b118bdeb | |||
5ddd7d1b14 | |||
450b23436f | |||
89793ac338 | |||
c456812a46 | |||
6f8f6e9233 | |||
5770d00f81 | |||
a0fb1eff4a | |||
89dc240a22 | |||
ee4f069341 | |||
011bb9f5b1 | |||
08b06e1b4e | |||
0416a2ff15 | |||
3a7cdfcaa4 | |||
c36a47576d | |||
6c9135c093 | |||
80ea9001b3 | |||
24bb94ceac | |||
0f16351f5f | |||
b60b26bbd0 | |||
86e53e08de | |||
d3cb10a74e | |||
7d6cfff719 | |||
cc0fe0a1a9 | |||
25327342fb | |||
e02cf67f85 | |||
bf4bef6d62 | |||
76645bce6e | |||
9276c491bc | |||
fa59b4f8a7 | |||
934a37c36b | |||
5362f62078 | |||
ccd360687e | |||
5a2e8a838c | |||
3abae1cc75 | |||
b68ef8c983 | |||
d5f5ba95bb | |||
e8638cb0b3 | |||
62f9071adc | |||
1d079dd9a4 | |||
cccb56bda1 | |||
5d8dc241d8 | |||
9ba7312caf | |||
8ebda219c4 | |||
47f14e8555 | |||
974a24d03b | |||
15f225537e | |||
a32572fc96 | |||
be3ed9b6af | |||
a0939e1c48 | |||
003dca9d45 | |||
5c1770247c | |||
021dde66eb | |||
5840a3e1e2 | |||
7c6478fe6b | |||
68aca55e6f | |||
ba674935f4 | |||
a053d55fbc | |||
38ba8852a3 | |||
3533359fae | |||
0a988d1c69 | |||
5f9e65cc9b | |||
026188268d | |||
0e3464457c | |||
56195434e7 | |||
ba2194f435 | |||
e7df172da1 | |||
e7606e6dca | |||
8d4c0f505c | |||
8f2878a841 | |||
77296348a0 | |||
a62a7d5330 | |||
bf60aae9d8 | |||
ecc1520100 | |||
f1f6a2b341 | |||
55bf1c31a6 | |||
e47dd3d587 | |||
af0e3a278f | |||
493ad93957 | |||
dbe8f3cfbe | |||
08cdac968d | |||
f12d5ba689 | |||
0afd77d110 | |||
7551941ef2 | |||
9ca0307e1c | |||
9a6f8be28c |
24
.gitattributes
vendored
Normal file
24
.gitattributes
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
* text=auto
|
||||||
|
* text eol=lf
|
||||||
|
|
||||||
|
# Windows forced line-endings
|
||||||
|
/.idea/* text eol=crlf
|
||||||
|
|
||||||
|
# Gradle wrapper
|
||||||
|
*.jar binary
|
||||||
|
|
||||||
|
# Images
|
||||||
|
*.webp binary
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.gz binary
|
||||||
|
*.zip binary
|
||||||
|
*.7z binary
|
||||||
|
*.ttf binary
|
||||||
|
*.eot binary
|
||||||
|
*.woff binary
|
||||||
|
*.pyc binary
|
||||||
|
*.swp binary
|
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
github: inorichi
|
||||||
|
ko_fi: inorichi
|
2
.github/ISSUE_TEMPLATE.md
vendored
2
.github/ISSUE_TEMPLATE.md
vendored
@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
**App version:**
|
**App version:**
|
||||||
|
|
||||||
|
**Android version:**
|
||||||
|
|
||||||
**Issue/Request:**
|
**Issue/Request:**
|
||||||
|
|
||||||
**Steps to reproduce (if applicable)**
|
**Steps to reproduce (if applicable)**
|
||||||
|
24
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
24
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: "🐞 Bug report"
|
||||||
|
about: Report a bug
|
||||||
|
title: "[Bug] Write short description here"
|
||||||
|
labels: "bug"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Device information
|
||||||
|
* Tachiyomi version: ?
|
||||||
|
* Android version: ?
|
||||||
|
|
||||||
|
## Steps to reproduce
|
||||||
|
1. First step
|
||||||
|
2. Second step
|
||||||
|
|
||||||
|
### Expected behavior
|
||||||
|
This should happen.
|
||||||
|
|
||||||
|
### Actual behavior
|
||||||
|
This happened instead.
|
||||||
|
|
||||||
|
### Other details
|
||||||
|
Additional details and attachments.
|
12
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
12
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: "🌟 Feature request"
|
||||||
|
about: Suggest a feature to improve Tachiyomi
|
||||||
|
title: "[Feature Request] Write short description here"
|
||||||
|
labels: "feature"
|
||||||
|
|
||||||
|
---
|
||||||
|
### Why/User Benefit/User Problem
|
||||||
|
(explain why this feature should be added)
|
||||||
|
|
||||||
|
### What/Requirements
|
||||||
|
(explain how this feature would behave)
|
BIN
.github/readme-images/screens.png
vendored
BIN
.github/readme-images/screens.png
vendored
Binary file not shown.
Before Width: | Height: | Size: 730 KiB After Width: | Height: | Size: 1.0 MiB |
@ -1,8 +1,9 @@
|
|||||||
|
dist: trusty
|
||||||
language: android
|
language: android
|
||||||
android:
|
android:
|
||||||
components:
|
components:
|
||||||
- build-tools-28.0.3
|
- build-tools-29.0.2
|
||||||
- android-27
|
- android-28
|
||||||
- extra-android-m2repository
|
- extra-android-m2repository
|
||||||
- extra-google-m2repository
|
- extra-google-m2repository
|
||||||
- extra-android-support
|
- extra-android-support
|
||||||
@ -10,7 +11,7 @@ android:
|
|||||||
licenses:
|
licenses:
|
||||||
- android-sdk-license-.+
|
- android-sdk-license-.+
|
||||||
before_install:
|
before_install:
|
||||||
- yes | sdkmanager "platforms;android-27" # workaround for accepting the license
|
- yes | sdkmanager "platforms;android-28" # workaround for accepting the license
|
||||||
- if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then
|
- if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then
|
||||||
openssl aes-256-cbc -K $encrypted_e56be693d4fd_key -iv $encrypted_e56be693d4fd_iv -in "$PWD/.travis/secrets.tar.enc" -out secrets.tar -d;
|
openssl aes-256-cbc -K $encrypted_e56be693d4fd_key -iv $encrypted_e56be693d4fd_iv -in "$PWD/.travis/secrets.tar.enc" -out secrets.tar -d;
|
||||||
tar xf secrets.tar;
|
tar xf secrets.tar;
|
||||||
|
16
README.md
16
README.md
@ -1,6 +1,6 @@
|
|||||||
| Build | Stable | Dev | Contribute | Contact |
|
| Build | Stable | Dev | Contribute | Support Server |
|
||||||
|-------|----------|---------|------------|---------|
|
|-------|----------|---------|------------|---------|
|
||||||
| [](https://travis-ci.org/inorichi/tachiyomi) | [)](https://github.com/inorichi/tachiyomi/releases) | [](http://tachiyomi.kanade.eu/latest) [](//github.com/inorichi/tachiyomi/wiki/F-Droid-for-dev-versions) | [](https://hosted.weblate.org/engage/tachiyomi/?utm_source=widget) | [](https://discord.gg/tachiyomi) |
|
| [](https://travis-ci.org/inorichi/tachiyomi) | [](https://github.com/inorichi/tachiyomi/releases) | [](http://tachiyomi.kanade.eu/latest) | [](https://hosted.weblate.org/engage/tachiyomi/?utm_source=widget) | [](https://discord.gg/tachiyomi) |
|
||||||
|
|
||||||
|
|
||||||
# Tachiyomi
|
# Tachiyomi
|
||||||
@ -11,10 +11,10 @@ Tachiyomi is a free and open source manga reader for Android.
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
Features include:
|
Features include:
|
||||||
* Online reading from sources such as KissManga, MangaFox, [and more](https://github.com/inorichi/tachiyomi-extensions)
|
* Online reading from sources such as KissManga, MangaDex, [and more](https://github.com/inorichi/tachiyomi-extensions)
|
||||||
* Local reading of downloaded manga
|
* Local reading of downloaded manga
|
||||||
* Configurable reader with multiple viewers, reading directions and other settings
|
* A configurable reader with multiple viewers, reading directions and other settings.
|
||||||
* [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), and [Kitsu](https://kitsu.io/explore/anime) support
|
* [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), [Kitsu](https://kitsu.io/explore/anime), and [Shikimori](https://shikimori.one) support
|
||||||
* Categories to organize your library
|
* Categories to organize your library
|
||||||
* Light and dark themes
|
* Light and dark themes
|
||||||
* Schedule updating your library for new chapters
|
* Schedule updating your library for new chapters
|
||||||
@ -23,7 +23,7 @@ Features include:
|
|||||||
## Download
|
## Download
|
||||||
Get the app from our [releases page](https://github.com/inorichi/tachiyomi/releases).
|
Get the app from our [releases page](https://github.com/inorichi/tachiyomi/releases).
|
||||||
|
|
||||||
If you want to try new features before they get to the stable release, you can download the dev version [here](http://tachiyomi.kanade.eu/latest) (auto-updates not included), or add our [F-Droid repo](https://github.com/inorichi/tachiyomi/wiki/F-Droid-for-dev-versions).
|
If you want to try new features before they get to the stable release, you can download the dev version [here](http://tachiyomi.kanade.eu/latest).
|
||||||
|
|
||||||
## Issues, Feature Requests and Contributing
|
## Issues, Feature Requests and Contributing
|
||||||
|
|
||||||
@ -63,8 +63,8 @@ Catalogue requests should be created at https://github.com/inorichi/tachiyomi-ex
|
|||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
[See our wiki.](https://github.com/inorichi/tachiyomi/wiki/FAQ)
|
[See our website.](https://tachiyomi.org/)
|
||||||
You can also reach out to us on [Discord](https://discord.gg/WrBkRk4).
|
You can also reach out to us on [Discord](https://discord.gg/tachiyomi).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
@ -29,17 +29,17 @@ ext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion 27
|
compileSdkVersion 28
|
||||||
buildToolsVersion '28.0.3'
|
buildToolsVersion '29.0.2'
|
||||||
publishNonDefault true
|
publishNonDefault true
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "eu.kanade.tachiyomi"
|
applicationId "eu.kanade.tachiyomi"
|
||||||
minSdkVersion 16
|
minSdkVersion 16
|
||||||
targetSdkVersion 27
|
targetSdkVersion 28
|
||||||
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
|
||||||
versionCode 39
|
versionCode 42
|
||||||
versionName "0.8.1"
|
versionName "0.8.5"
|
||||||
|
|
||||||
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
|
buildConfigField "String", "COMMIT_COUNT", "\"${getCommitCount()}\""
|
||||||
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
|
buildConfigField "String", "COMMIT_SHA", "\"${getGitSha()}\""
|
||||||
@ -48,6 +48,8 @@ android {
|
|||||||
|
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
|
|
||||||
|
multiDexEnabled true
|
||||||
|
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters "armeabi-v7a", "arm64-v8a", "x86"
|
abiFilters "armeabi-v7a", "arm64-v8a", "x86"
|
||||||
}
|
}
|
||||||
@ -57,13 +59,6 @@ android {
|
|||||||
debug {
|
debug {
|
||||||
versionNameSuffix "-${getCommitCount()}"
|
versionNameSuffix "-${getCommitCount()}"
|
||||||
applicationIdSuffix ".debug"
|
applicationIdSuffix ".debug"
|
||||||
multiDexEnabled true
|
|
||||||
}
|
|
||||||
release {
|
|
||||||
minifyEnabled true
|
|
||||||
shrinkResources true
|
|
||||||
multiDexEnabled false
|
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,7 +73,6 @@ android {
|
|||||||
dimension "default"
|
dimension "default"
|
||||||
}
|
}
|
||||||
dev {
|
dev {
|
||||||
minSdkVersion 21
|
|
||||||
resConfigs "en", "xxhdpi"
|
resConfigs "en", "xxhdpi"
|
||||||
dimension "default"
|
dimension "default"
|
||||||
}
|
}
|
||||||
@ -97,6 +91,14 @@ android {
|
|||||||
checkReleaseBuilds false
|
checkReleaseBuilds false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = 1.8
|
||||||
|
targetCompatibility = 1.8
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@ -106,7 +108,7 @@ dependencies {
|
|||||||
implementation 'com.github.inorichi:junrar-android:634c1f5'
|
implementation 'com.github.inorichi:junrar-android:634c1f5'
|
||||||
|
|
||||||
// Android support library
|
// Android support library
|
||||||
final support_library_version = '27.0.2'
|
final support_library_version = '28.0.0'
|
||||||
implementation "com.android.support:support-v4:$support_library_version"
|
implementation "com.android.support:support-v4:$support_library_version"
|
||||||
implementation "com.android.support:appcompat-v7:$support_library_version"
|
implementation "com.android.support:appcompat-v7:$support_library_version"
|
||||||
implementation "com.android.support:cardview-v7:$support_library_version"
|
implementation "com.android.support:cardview-v7:$support_library_version"
|
||||||
@ -116,7 +118,7 @@ dependencies {
|
|||||||
implementation "com.android.support:support-annotations:$support_library_version"
|
implementation "com.android.support:support-annotations:$support_library_version"
|
||||||
implementation "com.android.support:customtabs:$support_library_version"
|
implementation "com.android.support:customtabs:$support_library_version"
|
||||||
|
|
||||||
implementation 'com.android.support.constraint:constraint-layout:1.1.2'
|
implementation 'com.android.support.constraint:constraint-layout:1.1.3'
|
||||||
|
|
||||||
implementation 'com.android.support:multidex:1.0.3'
|
implementation 'com.android.support:multidex:1.0.3'
|
||||||
|
|
||||||
@ -124,10 +126,10 @@ dependencies {
|
|||||||
|
|
||||||
// ReactiveX
|
// ReactiveX
|
||||||
implementation 'io.reactivex:rxandroid:1.2.1'
|
implementation 'io.reactivex:rxandroid:1.2.1'
|
||||||
implementation 'io.reactivex:rxjava:1.3.6'
|
implementation 'io.reactivex:rxjava:1.3.8'
|
||||||
implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0'
|
implementation 'com.jakewharton.rxrelay:rxrelay:1.2.0'
|
||||||
implementation 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
|
implementation 'com.f2prateek.rx.preferences:rx-preferences:1.0.2'
|
||||||
implementation 'com.github.pwittchen:reactivenetwork:0.7.0'
|
implementation 'com.github.pwittchen:reactivenetwork:0.13.0'
|
||||||
|
|
||||||
// Network client
|
// Network client
|
||||||
implementation "com.squareup.okhttp3:okhttp:3.10.0"
|
implementation "com.squareup.okhttp3:okhttp:3.10.0"
|
||||||
@ -151,7 +153,7 @@ dependencies {
|
|||||||
implementation 'com.github.inorichi:unifile:e9ee588'
|
implementation 'com.github.inorichi:unifile:e9ee588'
|
||||||
|
|
||||||
// HTML parser
|
// HTML parser
|
||||||
implementation 'org.jsoup:jsoup:1.10.2'
|
implementation 'org.jsoup:jsoup:1.12.1'
|
||||||
|
|
||||||
// Job scheduling
|
// Job scheduling
|
||||||
implementation 'com.evernote:android-job:1.2.5'
|
implementation 'com.evernote:android-job:1.2.5'
|
||||||
@ -161,7 +163,9 @@ dependencies {
|
|||||||
implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
|
implementation 'com.github.gabrielemariotti.changeloglib:changelog:2.1.0'
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
implementation 'eu.kanade.storio:storio:1.13.0'
|
implementation 'android.arch.persistence:db:1.1.1'
|
||||||
|
implementation 'com.github.inorichi.storio:storio-common:8be19de@aar'
|
||||||
|
implementation 'com.github.inorichi.storio:storio-sqlite:8be19de@aar'
|
||||||
implementation 'io.requery:sqlite-android:3.25.2'
|
implementation 'io.requery:sqlite-android:3.25.2'
|
||||||
|
|
||||||
// Model View Presenter
|
// Model View Presenter
|
||||||
@ -182,14 +186,11 @@ dependencies {
|
|||||||
implementation 'jp.wasabeef:glide-transformations:3.1.1'
|
implementation 'jp.wasabeef:glide-transformations:3.1.1'
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
implementation 'com.jakewharton.timber:timber:4.6.1'
|
implementation 'com.jakewharton.timber:timber:4.7.1'
|
||||||
|
|
||||||
// Crash reports
|
// Crash reports
|
||||||
implementation 'ch.acra:acra:4.9.2'
|
implementation 'ch.acra:acra:4.9.2'
|
||||||
|
|
||||||
// Sort
|
|
||||||
implementation 'com.github.gpanther:java-nat-sort:natural-comparator-1.1'
|
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
implementation 'com.dmitrymalkovich.android:material-design-dimens:1.4'
|
implementation 'com.dmitrymalkovich.android:material-design-dimens:1.4'
|
||||||
implementation 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
|
implementation 'com.github.dmytrodanylyk.android-process-button:library:1.0.4'
|
||||||
@ -231,13 +232,12 @@ dependencies {
|
|||||||
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
||||||
|
|
||||||
final coroutines_version = '0.22.2'
|
final coroutines_version = '1.3.3'
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.2.71'
|
ext.kotlin_version = '1.3.61'
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
@ -250,10 +250,9 @@ repositories {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
// See https://kotlinlang.org/docs/reference/experimental.html#experimental-status-of-experimental-api-markers
|
||||||
experimental {
|
tasks.withType(org.jetbrains.kotlin.gradle.tasks.AbstractKotlinCompile).all {
|
||||||
coroutines 'enable'
|
kotlinOptions.freeCompilerArgs += ["-Xuse-experimental=kotlin.Experimental"]
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
androidExtensions {
|
androidExtensions {
|
||||||
|
@ -9,12 +9,16 @@
|
|||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
|
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".App"
|
android:name=".App"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@ -22,12 +26,21 @@
|
|||||||
android:theme="@style/Theme.Tachiyomi">
|
android:theme="@style/Theme.Tachiyomi">
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.main.MainActivity"
|
android:name=".ui.main.MainActivity"
|
||||||
android:launchMode="singleTop">
|
android:launchMode="singleTask">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEARCH" />
|
||||||
|
<action android:name="com.google.android.gms.actions.SEARCH_ACTION"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="eu.kanade.tachiyomi.SEARCH" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.app.searchable" android:resource="@xml/searchable"/>
|
||||||
<!--suppress AndroidDomInspection -->
|
<!--suppress AndroidDomInspection -->
|
||||||
<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/>
|
<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts"/>
|
||||||
</activity>
|
</activity>
|
||||||
@ -51,6 +64,35 @@
|
|||||||
android:scheme="tachiyomi" />
|
android:scheme="tachiyomi" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".ui.setting.ShikimoriLoginActivity"
|
||||||
|
android:label="Shikimori">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<data
|
||||||
|
android:host="shikimori-auth"
|
||||||
|
android:scheme="tachiyomi" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".ui.setting.BangumiLoginActivity"
|
||||||
|
android:label="Bangumi">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
|
<data
|
||||||
|
android:host="bangumi-auth"
|
||||||
|
android:scheme="tachiyomi" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".extension.util.ExtensionInstallActivity"
|
android:name=".extension.util.ExtensionInstallActivity"
|
||||||
android:theme="@android:style/Theme.Translucent.NoTitleBar"/>
|
android:theme="@android:style/Theme.Translucent.NoTitleBar"/>
|
||||||
|
@ -42,9 +42,7 @@ open class App : Application() {
|
|||||||
|
|
||||||
override fun attachBaseContext(base: Context) {
|
override fun attachBaseContext(base: Context) {
|
||||||
super.attachBaseContext(base)
|
super.attachBaseContext(base)
|
||||||
if (BuildConfig.DEBUG) {
|
MultiDex.install(this)
|
||||||
MultiDex.install(this)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
|
@ -1,23 +1,23 @@
|
|||||||
package eu.kanade.tachiyomi.data.backup.models
|
package eu.kanade.tachiyomi.data.backup.models
|
||||||
|
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Json values
|
* Json values
|
||||||
*/
|
*/
|
||||||
object Backup {
|
object Backup {
|
||||||
const val CURRENT_VERSION = 2
|
const val CURRENT_VERSION = 2
|
||||||
const val MANGA = "manga"
|
const val MANGA = "manga"
|
||||||
const val MANGAS = "mangas"
|
const val MANGAS = "mangas"
|
||||||
const val TRACK = "track"
|
const val TRACK = "track"
|
||||||
const val CHAPTERS = "chapters"
|
const val CHAPTERS = "chapters"
|
||||||
const val CATEGORIES = "categories"
|
const val CATEGORIES = "categories"
|
||||||
const val HISTORY = "history"
|
const val HISTORY = "history"
|
||||||
const val VERSION = "version"
|
const val VERSION = "version"
|
||||||
|
|
||||||
fun getDefaultFilename(): String {
|
fun getDefaultFilename(): String {
|
||||||
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date())
|
val date = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.getDefault()).format(Date())
|
||||||
return "tachiyomi_$date.json"
|
return "tachiyomi_$date.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -60,7 +60,7 @@ class CoverCache(private val context: Context) {
|
|||||||
return false
|
return false
|
||||||
|
|
||||||
// Remove file.
|
// Remove file.
|
||||||
val file = getCoverFile(thumbnailUrl!!)
|
val file = getCoverFile(thumbnailUrl)
|
||||||
return file.exists() && file.delete()
|
return file.exists() && file.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,6 +82,11 @@ interface MangaQueries : DbProvider {
|
|||||||
.withPutResolver(MangaViewerPutResolver())
|
.withPutResolver(MangaViewerPutResolver())
|
||||||
.prepare()
|
.prepare()
|
||||||
|
|
||||||
|
fun updateMangaTitle(manga: Manga) = db.put()
|
||||||
|
.`object`(manga)
|
||||||
|
.withPutResolver(MangaTitlePutResolver())
|
||||||
|
.prepare()
|
||||||
|
|
||||||
fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
|
fun deleteManga(manga: Manga) = db.delete().`object`(manga).prepare()
|
||||||
|
|
||||||
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()
|
fun deleteMangas(mangas: List<Manga>) = db.delete().objects(mangas).prepare()
|
||||||
|
@ -1,34 +1,34 @@
|
|||||||
package eu.kanade.tachiyomi.data.database.queries
|
package eu.kanade.tachiyomi.data.database.queries
|
||||||
|
|
||||||
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
import com.pushtorefresh.storio.sqlite.queries.DeleteQuery
|
||||||
import com.pushtorefresh.storio.sqlite.queries.Query
|
import com.pushtorefresh.storio.sqlite.queries.Query
|
||||||
import eu.kanade.tachiyomi.data.database.DbProvider
|
import eu.kanade.tachiyomi.data.database.DbProvider
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.database.tables.TrackTable
|
import eu.kanade.tachiyomi.data.database.tables.TrackTable
|
||||||
import eu.kanade.tachiyomi.data.track.TrackService
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
|
|
||||||
interface TrackQueries : DbProvider {
|
interface TrackQueries : DbProvider {
|
||||||
|
|
||||||
fun getTracks(manga: Manga) = db.get()
|
fun getTracks(manga: Manga) = db.get()
|
||||||
.listOfObjects(Track::class.java)
|
.listOfObjects(Track::class.java)
|
||||||
.withQuery(Query.builder()
|
.withQuery(Query.builder()
|
||||||
.table(TrackTable.TABLE)
|
.table(TrackTable.TABLE)
|
||||||
.where("${TrackTable.COL_MANGA_ID} = ?")
|
.where("${TrackTable.COL_MANGA_ID} = ?")
|
||||||
.whereArgs(manga.id)
|
.whereArgs(manga.id)
|
||||||
.build())
|
.build())
|
||||||
.prepare()
|
.prepare()
|
||||||
|
|
||||||
fun insertTrack(track: Track) = db.put().`object`(track).prepare()
|
fun insertTrack(track: Track) = db.put().`object`(track).prepare()
|
||||||
|
|
||||||
fun insertTracks(tracks: List<Track>) = db.put().objects(tracks).prepare()
|
fun insertTracks(tracks: List<Track>) = db.put().objects(tracks).prepare()
|
||||||
|
|
||||||
fun deleteTrackForManga(manga: Manga, sync: TrackService) = db.delete()
|
fun deleteTrackForManga(manga: Manga, sync: TrackService) = db.delete()
|
||||||
.byQuery(DeleteQuery.builder()
|
.byQuery(DeleteQuery.builder()
|
||||||
.table(TrackTable.TABLE)
|
.table(TrackTable.TABLE)
|
||||||
.where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?")
|
.where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?")
|
||||||
.whereArgs(manga.id, sync.id)
|
.whereArgs(manga.id, sync.id)
|
||||||
.build())
|
.build())
|
||||||
.prepare()
|
.prepare()
|
||||||
|
|
||||||
}
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.database.resolvers
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import com.pushtorefresh.storio.sqlite.StorIOSQLite
|
||||||
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResolver
|
||||||
|
import com.pushtorefresh.storio.sqlite.operations.put.PutResult
|
||||||
|
import com.pushtorefresh.storio.sqlite.queries.UpdateQuery
|
||||||
|
import eu.kanade.tachiyomi.data.database.inTransactionReturn
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.data.database.tables.MangaTable
|
||||||
|
|
||||||
|
class MangaTitlePutResolver : PutResolver<Manga>() {
|
||||||
|
|
||||||
|
override fun performPut(db: StorIOSQLite, manga: Manga) = db.inTransactionReturn {
|
||||||
|
val updateQuery = mapToUpdateQuery(manga)
|
||||||
|
val contentValues = mapToContentValues(manga)
|
||||||
|
|
||||||
|
val numberOfRowsUpdated = db.lowLevel().update(updateQuery, contentValues)
|
||||||
|
PutResult.newUpdateResult(numberOfRowsUpdated, updateQuery.table())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
|
||||||
|
.table(MangaTable.TABLE)
|
||||||
|
.where("${MangaTable.COL_ID} = ?")
|
||||||
|
.whereArgs(manga.id)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
|
||||||
|
put(MangaTable.COL_TITLE, manga.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -20,10 +20,10 @@ class MangaViewerPutResolver : PutResolver<Manga>() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
|
fun mapToUpdateQuery(manga: Manga) = UpdateQuery.builder()
|
||||||
.table(MangaTable.TABLE)
|
.table(MangaTable.TABLE)
|
||||||
.where("${MangaTable.COL_ID} = ?")
|
.where("${MangaTable.COL_ID} = ?")
|
||||||
.whereArgs(manga.id)
|
.whereArgs(manga.id)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
|
fun mapToContentValues(manga: Manga) = ContentValues(1).apply {
|
||||||
put(MangaTable.COL_VIEWER, manga.viewer)
|
put(MangaTable.COL_VIEWER, manga.viewer)
|
||||||
|
@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.download
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.hippo.unifile.UniFile
|
import com.hippo.unifile.UniFile
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.models.Chapter
|
import eu.kanade.tachiyomi.data.database.models.Chapter
|
||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
@ -28,7 +29,9 @@ class DownloadProvider(private val context: Context) {
|
|||||||
* The root directory for downloads.
|
* The root directory for downloads.
|
||||||
*/
|
*/
|
||||||
private var downloadsDir = preferences.downloadsDirectory().getOrDefault().let {
|
private var downloadsDir = preferences.downloadsDirectory().getOrDefault().let {
|
||||||
UniFile.fromUri(context, Uri.parse(it))
|
val dir = UniFile.fromUri(context, Uri.parse(it))
|
||||||
|
DiskUtil.createNoMediaFile(dir, context)
|
||||||
|
dir
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@ -44,9 +47,13 @@ class DownloadProvider(private val context: Context) {
|
|||||||
* @param source the source of the manga.
|
* @param source the source of the manga.
|
||||||
*/
|
*/
|
||||||
internal fun getMangaDir(manga: Manga, source: Source): UniFile {
|
internal fun getMangaDir(manga: Manga, source: Source): UniFile {
|
||||||
return downloadsDir
|
try {
|
||||||
.createDirectory(getSourceDirName(source))
|
return downloadsDir
|
||||||
.createDirectory(getMangaDirName(manga))
|
.createDirectory(getSourceDirName(source))
|
||||||
|
.createDirectory(getMangaDirName(manga))
|
||||||
|
} catch (e: NullPointerException) {
|
||||||
|
throw Exception(context.getString(R.string.invalid_download_dir))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -132,7 +132,7 @@ class DownloadService : Service() {
|
|||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe({ state -> onNetworkStateChanged(state)
|
.subscribe({ state -> onNetworkStateChanged(state)
|
||||||
}, { _ ->
|
}, {
|
||||||
toast(R.string.download_queue_error)
|
toast(R.string.download_queue_error)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
})
|
})
|
||||||
|
@ -14,7 +14,7 @@ import eu.kanade.tachiyomi.source.model.Page
|
|||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.source.online.fetchAllImageUrlsFromPageList
|
import eu.kanade.tachiyomi.source.online.fetchAllImageUrlsFromPageList
|
||||||
import eu.kanade.tachiyomi.util.*
|
import eu.kanade.tachiyomi.util.*
|
||||||
import kotlinx.coroutines.experimental.async
|
import kotlinx.coroutines.async
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
@ -102,7 +102,7 @@ class Downloader(
|
|||||||
pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE }
|
pending.forEach { if (it.status != Download.QUEUE) it.status = Download.QUEUE }
|
||||||
|
|
||||||
downloadsRelay.call(pending)
|
downloadsRelay.call(pending)
|
||||||
return !pending.isEmpty()
|
return pending.isNotEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -199,7 +199,7 @@ class Downloader(
|
|||||||
*/
|
*/
|
||||||
fun queueChapters(manga: Manga, chapters: List<Chapter>, autoStart: Boolean) = launchUI {
|
fun queueChapters(manga: Manga, chapters: List<Chapter>, autoStart: Boolean) = launchUI {
|
||||||
val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchUI
|
val source = sourceManager.get(manga.source) as? HttpSource ?: return@launchUI
|
||||||
|
val wasEmpty = queue.isEmpty()
|
||||||
// Called in background thread, the operation can be slow with SAF.
|
// Called in background thread, the operation can be slow with SAF.
|
||||||
val chaptersWithoutDir = async {
|
val chaptersWithoutDir = async {
|
||||||
val mangaDir = provider.findMangaDir(manga, source)
|
val mangaDir = provider.findMangaDir(manga, source)
|
||||||
@ -232,7 +232,7 @@ class Downloader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start downloader if needed
|
// Start downloader if needed
|
||||||
if (autoStart) {
|
if (autoStart && wasEmpty) {
|
||||||
DownloadService.start(this@Downloader.context)
|
DownloadService.start(this@Downloader.context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -407,6 +407,8 @@ class Downloader(
|
|||||||
if (download.status == Download.DOWNLOADED) {
|
if (download.status == Download.DOWNLOADED) {
|
||||||
tmpDir.renameTo(dirname)
|
tmpDir.renameTo(dirname)
|
||||||
cache.addChapter(dirname, mangaDir, download.manga)
|
cache.addChapter(dirname, mangaDir, download.manga)
|
||||||
|
|
||||||
|
DiskUtil.createNoMediaFile(tmpDir, context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,43 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.library
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class will provide various functions to Rank mangas to efficiently schedule mangas to update.
|
||||||
|
*/
|
||||||
|
object LibraryUpdateRanker {
|
||||||
|
|
||||||
|
val rankingScheme = listOf(
|
||||||
|
(this::lexicographicRanking)(),
|
||||||
|
(this::latestFirstRanking)())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a total ordering over all the Mangas.
|
||||||
|
*
|
||||||
|
* Assumption: An active [Manga] mActive is expected to have been last updated after an
|
||||||
|
* inactive [Manga] mInactive.
|
||||||
|
*
|
||||||
|
* Using this insight, function returns a Comparator for which mActive appears before mInactive.
|
||||||
|
* @return a Comparator that ranks manga based on relevance.
|
||||||
|
*/
|
||||||
|
fun latestFirstRanking(): Comparator<Manga> {
|
||||||
|
return Comparator { mangaFirst: Manga,
|
||||||
|
mangaSecond: Manga ->
|
||||||
|
compareValues(mangaSecond.last_update, mangaFirst.last_update)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a total ordering over all the Mangas.
|
||||||
|
*
|
||||||
|
* Order the manga lexicographically.
|
||||||
|
* @return a Comparator that ranks manga lexicographically based on the title.
|
||||||
|
*/
|
||||||
|
fun lexicographicRanking(): Comparator<Manga> {
|
||||||
|
return Comparator { mangaFirst: Manga,
|
||||||
|
mangaSecond: Manga ->
|
||||||
|
compareValues(mangaFirst.title, mangaSecond.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -18,6 +18,7 @@ import eu.kanade.tachiyomi.data.database.models.LibraryManga
|
|||||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadManager
|
import eu.kanade.tachiyomi.data.download.DownloadManager
|
||||||
import eu.kanade.tachiyomi.data.download.DownloadService
|
import eu.kanade.tachiyomi.data.download.DownloadService
|
||||||
|
import eu.kanade.tachiyomi.data.library.LibraryUpdateRanker.rankingScheme
|
||||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start
|
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start
|
||||||
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
|
||||||
import eu.kanade.tachiyomi.data.notification.Notifications
|
import eu.kanade.tachiyomi.data.notification.Notifications
|
||||||
@ -204,7 +205,9 @@ class LibraryUpdateService(
|
|||||||
// Update favorite manga. Destroy service when completed or in case of an error.
|
// Update favorite manga. Destroy service when completed or in case of an error.
|
||||||
subscription = Observable
|
subscription = Observable
|
||||||
.defer {
|
.defer {
|
||||||
|
val selectedScheme = preferences.libraryUpdatePrioritization().getOrDefault()
|
||||||
val mangaList = getMangaToUpdate(intent, target)
|
val mangaList = getMangaToUpdate(intent, target)
|
||||||
|
.sortedWith(rankingScheme[selectedScheme])
|
||||||
|
|
||||||
// Update either chapter list or manga details.
|
// Update either chapter list or manga details.
|
||||||
when (target) {
|
when (target) {
|
||||||
@ -246,7 +249,6 @@ class LibraryUpdateService(
|
|||||||
else
|
else
|
||||||
db.getLibraryMangas().executeAsBlocking().distinctBy { it.id }
|
db.getLibraryMangas().executeAsBlocking().distinctBy { it.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target == Target.CHAPTERS && preferences.updateOnlyNonCompleted()) {
|
if (target == Target.CHAPTERS && preferences.updateOnlyNonCompleted()) {
|
||||||
listToUpdate = listToUpdate.filter { it.status != SManga.COMPLETED }
|
listToUpdate = listToUpdate.filter { it.status != SManga.COMPLETED }
|
||||||
}
|
}
|
||||||
|
@ -1,124 +1,132 @@
|
|||||||
package eu.kanade.tachiyomi.data.preference
|
package eu.kanade.tachiyomi.data.preference
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class stores the keys for the preferences in the application.
|
* This class stores the keys for the preferences in the application.
|
||||||
*/
|
*/
|
||||||
object PreferenceKeys {
|
object PreferenceKeys {
|
||||||
|
|
||||||
const val theme = "pref_theme_key"
|
const val theme = "pref_theme_key"
|
||||||
|
|
||||||
const val rotation = "pref_rotation_type_key"
|
const val rotation = "pref_rotation_type_key"
|
||||||
|
|
||||||
const val enableTransitions = "pref_enable_transitions_key"
|
const val enableTransitions = "pref_enable_transitions_key"
|
||||||
|
|
||||||
const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed"
|
const val doubleTapAnimationSpeed = "pref_double_tap_anim_speed"
|
||||||
|
|
||||||
const val showPageNumber = "pref_show_page_number_key"
|
const val showPageNumber = "pref_show_page_number_key"
|
||||||
|
|
||||||
const val fullscreen = "fullscreen"
|
const val trueColor = "pref_true_color_key"
|
||||||
|
|
||||||
const val keepScreenOn = "pref_keep_screen_on_key"
|
const val fullscreen = "fullscreen"
|
||||||
|
|
||||||
const val customBrightness = "pref_custom_brightness_key"
|
const val keepScreenOn = "pref_keep_screen_on_key"
|
||||||
|
|
||||||
const val customBrightnessValue = "custom_brightness_value"
|
const val customBrightness = "pref_custom_brightness_key"
|
||||||
|
|
||||||
const val colorFilter = "pref_color_filter_key"
|
const val customBrightnessValue = "custom_brightness_value"
|
||||||
|
|
||||||
const val colorFilterValue = "color_filter_value"
|
const val colorFilter = "pref_color_filter_key"
|
||||||
|
|
||||||
const val defaultViewer = "pref_default_viewer_key"
|
const val colorFilterValue = "color_filter_value"
|
||||||
|
|
||||||
const val imageScaleType = "pref_image_scale_type_key"
|
const val colorFilterMode = "color_filter_mode"
|
||||||
|
|
||||||
const val zoomStart = "pref_zoom_start_key"
|
const val defaultViewer = "pref_default_viewer_key"
|
||||||
|
|
||||||
const val readerTheme = "pref_reader_theme_key"
|
const val imageScaleType = "pref_image_scale_type_key"
|
||||||
|
|
||||||
const val cropBorders = "crop_borders"
|
const val zoomStart = "pref_zoom_start_key"
|
||||||
|
|
||||||
const val cropBordersWebtoon = "crop_borders_webtoon"
|
const val readerTheme = "pref_reader_theme_key"
|
||||||
|
|
||||||
const val readWithTapping = "reader_tap"
|
const val cropBorders = "crop_borders"
|
||||||
|
|
||||||
const val readWithLongTap = "reader_long_tap"
|
const val cropBordersWebtoon = "crop_borders_webtoon"
|
||||||
|
|
||||||
const val readWithVolumeKeys = "reader_volume_keys"
|
const val readWithTapping = "reader_tap"
|
||||||
|
|
||||||
const val readWithVolumeKeysInverted = "reader_volume_keys_inverted"
|
const val readWithLongTap = "reader_long_tap"
|
||||||
|
|
||||||
const val portraitColumns = "pref_library_columns_portrait_key"
|
const val readWithVolumeKeys = "reader_volume_keys"
|
||||||
|
|
||||||
const val landscapeColumns = "pref_library_columns_landscape_key"
|
const val readWithVolumeKeysInverted = "reader_volume_keys_inverted"
|
||||||
|
|
||||||
const val updateOnlyNonCompleted = "pref_update_only_non_completed_key"
|
const val portraitColumns = "pref_library_columns_portrait_key"
|
||||||
|
|
||||||
const val autoUpdateTrack = "pref_auto_update_manga_sync_key"
|
const val landscapeColumns = "pref_library_columns_landscape_key"
|
||||||
|
|
||||||
const val lastUsedCatalogueSource = "last_catalogue_source"
|
const val updateOnlyNonCompleted = "pref_update_only_non_completed_key"
|
||||||
|
|
||||||
const val lastUsedCategory = "last_used_category"
|
const val autoUpdateTrack = "pref_auto_update_manga_sync_key"
|
||||||
|
|
||||||
const val catalogueAsList = "pref_display_catalogue_as_list"
|
const val lastUsedCatalogueSource = "last_catalogue_source"
|
||||||
|
|
||||||
const val enabledLanguages = "source_languages"
|
const val lastUsedCategory = "last_used_category"
|
||||||
|
|
||||||
const val backupDirectory = "backup_directory"
|
const val catalogueAsList = "pref_display_catalogue_as_list"
|
||||||
|
|
||||||
const val downloadsDirectory = "download_directory"
|
const val enabledLanguages = "source_languages"
|
||||||
|
|
||||||
const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key"
|
const val backupDirectory = "backup_directory"
|
||||||
|
|
||||||
const val numberOfBackups = "backup_slots"
|
const val downloadsDirectory = "download_directory"
|
||||||
|
|
||||||
const val backupInterval = "backup_interval"
|
const val downloadOnlyOverWifi = "pref_download_only_over_wifi_key"
|
||||||
|
|
||||||
const val removeAfterReadSlots = "remove_after_read_slots"
|
const val numberOfBackups = "backup_slots"
|
||||||
|
|
||||||
const val removeAfterMarkedAsRead = "pref_remove_after_marked_as_read_key"
|
const val backupInterval = "backup_interval"
|
||||||
|
|
||||||
const val libraryUpdateInterval = "pref_library_update_interval_key"
|
const val removeAfterReadSlots = "remove_after_read_slots"
|
||||||
|
|
||||||
const val libraryUpdateRestriction = "library_update_restriction"
|
const val removeAfterMarkedAsRead = "pref_remove_after_marked_as_read_key"
|
||||||
|
|
||||||
const val libraryUpdateCategories = "library_update_categories"
|
const val libraryUpdateInterval = "pref_library_update_interval_key"
|
||||||
|
|
||||||
const val filterDownloaded = "pref_filter_downloaded_key"
|
const val libraryUpdateRestriction = "library_update_restriction"
|
||||||
|
|
||||||
const val filterUnread = "pref_filter_unread_key"
|
const val libraryUpdateCategories = "library_update_categories"
|
||||||
|
|
||||||
const val filterCompleted = "pref_filter_completed_key"
|
const val libraryUpdatePrioritization = "library_update_prioritization"
|
||||||
|
|
||||||
const val librarySortingMode = "library_sorting_mode"
|
const val filterDownloaded = "pref_filter_downloaded_key"
|
||||||
|
|
||||||
const val automaticUpdates = "automatic_updates"
|
const val filterUnread = "pref_filter_unread_key"
|
||||||
|
|
||||||
const val startScreen = "start_screen"
|
const val filterCompleted = "pref_filter_completed_key"
|
||||||
|
|
||||||
const val downloadNew = "download_new"
|
const val librarySortingMode = "library_sorting_mode"
|
||||||
|
|
||||||
const val downloadNewCategories = "download_new_categories"
|
const val automaticUpdates = "automatic_updates"
|
||||||
|
|
||||||
const val libraryAsList = "pref_display_library_as_list"
|
const val startScreen = "start_screen"
|
||||||
|
|
||||||
const val lang = "app_language"
|
const val downloadNew = "download_new"
|
||||||
|
|
||||||
const val defaultCategory = "default_category"
|
const val downloadNewCategories = "download_new_categories"
|
||||||
|
|
||||||
const val downloadBadge = "display_download_badge"
|
const val libraryAsList = "pref_display_library_as_list"
|
||||||
|
|
||||||
@Deprecated("Use the preferences of the source")
|
const val lang = "app_language"
|
||||||
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
|
|
||||||
|
const val defaultCategory = "default_category"
|
||||||
@Deprecated("Use the preferences of the source")
|
|
||||||
fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId"
|
const val skipRead = "skip_read"
|
||||||
|
|
||||||
fun sourceSharedPref(sourceId: Long) = "source_$sourceId"
|
const val downloadBadge = "display_download_badge"
|
||||||
|
|
||||||
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
|
@Deprecated("Use the preferences of the source")
|
||||||
|
fun sourceUsername(sourceId: Long) = "pref_source_username_$sourceId"
|
||||||
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"
|
|
||||||
|
@Deprecated("Use the preferences of the source")
|
||||||
fun trackToken(syncId: Int) = "track_token_$syncId"
|
fun sourcePassword(sourceId: Long) = "pref_source_password_$sourceId"
|
||||||
|
|
||||||
}
|
fun sourceSharedPref(sourceId: Long) = "source_$sourceId"
|
||||||
|
|
||||||
|
fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId"
|
||||||
|
|
||||||
|
fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId"
|
||||||
|
|
||||||
|
fun trackToken(syncId: Int) = "track_token_$syncId"
|
||||||
|
|
||||||
|
}
|
||||||
|
@ -43,6 +43,8 @@ class PreferencesHelper(val context: Context) {
|
|||||||
|
|
||||||
fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true)
|
fun showPageNumber() = rxPrefs.getBoolean(Keys.showPageNumber, true)
|
||||||
|
|
||||||
|
fun trueColor() = rxPrefs.getBoolean(Keys.trueColor, false)
|
||||||
|
|
||||||
fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true)
|
fun fullscreen() = rxPrefs.getBoolean(Keys.fullscreen, true)
|
||||||
|
|
||||||
fun keepScreenOn() = rxPrefs.getBoolean(Keys.keepScreenOn, true)
|
fun keepScreenOn() = rxPrefs.getBoolean(Keys.keepScreenOn, true)
|
||||||
@ -55,6 +57,8 @@ class PreferencesHelper(val context: Context) {
|
|||||||
|
|
||||||
fun colorFilterValue() = rxPrefs.getInteger(Keys.colorFilterValue, 0)
|
fun colorFilterValue() = rxPrefs.getInteger(Keys.colorFilterValue, 0)
|
||||||
|
|
||||||
|
fun colorFilterMode() = rxPrefs.getInteger(Keys.colorFilterMode, 0)
|
||||||
|
|
||||||
fun defaultViewer() = prefs.getInt(Keys.defaultViewer, 1)
|
fun defaultViewer() = prefs.getInt(Keys.defaultViewer, 1)
|
||||||
|
|
||||||
fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1)
|
fun imageScaleType() = rxPrefs.getInteger(Keys.imageScaleType, 1)
|
||||||
@ -139,6 +143,8 @@ class PreferencesHelper(val context: Context) {
|
|||||||
|
|
||||||
fun libraryUpdateCategories() = rxPrefs.getStringSet(Keys.libraryUpdateCategories, emptySet())
|
fun libraryUpdateCategories() = rxPrefs.getStringSet(Keys.libraryUpdateCategories, emptySet())
|
||||||
|
|
||||||
|
fun libraryUpdatePrioritization() = rxPrefs.getInteger(Keys.libraryUpdatePrioritization, 0)
|
||||||
|
|
||||||
fun libraryAsList() = rxPrefs.getBoolean(Keys.libraryAsList, false)
|
fun libraryAsList() = rxPrefs.getBoolean(Keys.libraryAsList, false)
|
||||||
|
|
||||||
fun downloadBadge() = rxPrefs.getBoolean(Keys.downloadBadge, false)
|
fun downloadBadge() = rxPrefs.getBoolean(Keys.downloadBadge, false)
|
||||||
@ -165,6 +171,8 @@ class PreferencesHelper(val context: Context) {
|
|||||||
|
|
||||||
fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1)
|
fun defaultCategory() = prefs.getInt(Keys.defaultCategory, -1)
|
||||||
|
|
||||||
|
fun skipRead() = prefs.getBoolean(Keys.skipRead, false)
|
||||||
|
|
||||||
fun migrateFlags() = rxPrefs.getInteger("migrate_flags", Int.MAX_VALUE)
|
fun migrateFlags() = rxPrefs.getInteger("migrate_flags", Int.MAX_VALUE)
|
||||||
|
|
||||||
fun trustedSignatures() = rxPrefs.getStringSet("trusted_signatures", emptySet())
|
fun trustedSignatures() = rxPrefs.getStringSet("trusted_signatures", emptySet())
|
||||||
|
@ -45,11 +45,11 @@ class SharedPreferencesDataStore(private val prefs: SharedPreferences) : Prefere
|
|||||||
prefs.edit().putString(key, value).apply()
|
prefs.edit().putString(key, value).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String> {
|
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String>? {
|
||||||
return prefs.getStringSet(key, defValues)
|
return prefs.getStringSet(key, defValues)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun putStringSet(key: String?, values: MutableSet<String>?) {
|
override fun putStringSet(key: String?, values: MutableSet<String>?) {
|
||||||
prefs.edit().putStringSet(key, values).apply()
|
prefs.edit().putStringSet(key, values).apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,28 +1,36 @@
|
|||||||
package eu.kanade.tachiyomi.data.track
|
package eu.kanade.tachiyomi.data.track
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import eu.kanade.tachiyomi.data.track.anilist.Anilist
|
import eu.kanade.tachiyomi.data.track.anilist.Anilist
|
||||||
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
|
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
|
||||||
import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist
|
import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist
|
||||||
|
import eu.kanade.tachiyomi.data.track.shikimori.Shikimori
|
||||||
class TrackManager(private val context: Context) {
|
import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
|
||||||
|
|
||||||
companion object {
|
class TrackManager(private val context: Context) {
|
||||||
const val MYANIMELIST = 1
|
|
||||||
const val ANILIST = 2
|
companion object {
|
||||||
const val KITSU = 3
|
const val MYANIMELIST = 1
|
||||||
}
|
const val ANILIST = 2
|
||||||
|
const val KITSU = 3
|
||||||
val myAnimeList = Myanimelist(context, MYANIMELIST)
|
const val SHIKIMORI = 4
|
||||||
|
const val BANGUMI = 5
|
||||||
val aniList = Anilist(context, ANILIST)
|
}
|
||||||
|
|
||||||
val kitsu = Kitsu(context, KITSU)
|
val myAnimeList = Myanimelist(context, MYANIMELIST)
|
||||||
|
|
||||||
val services = listOf(myAnimeList, aniList, kitsu)
|
val aniList = Anilist(context, ANILIST)
|
||||||
|
|
||||||
fun getService(id: Int) = services.find { it.id == id }
|
val kitsu = Kitsu(context, KITSU)
|
||||||
|
|
||||||
fun hasLoggedServices() = services.any { it.isLogged }
|
val shikimori = Shikimori(context, SHIKIMORI)
|
||||||
|
|
||||||
}
|
val bangumi = Bangumi(context, BANGUMI)
|
||||||
|
|
||||||
|
val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi)
|
||||||
|
|
||||||
|
fun getService(id: Int) = services.find { it.id == id }
|
||||||
|
|
||||||
|
fun hasLoggedServices() = services.any { it.isLogged }
|
||||||
|
|
||||||
|
}
|
||||||
|
@ -1,70 +1,70 @@
|
|||||||
package eu.kanade.tachiyomi.data.track
|
package eu.kanade.tachiyomi.data.track
|
||||||
|
|
||||||
import android.support.annotation.CallSuper
|
import android.support.annotation.CallSuper
|
||||||
import android.support.annotation.DrawableRes
|
import android.support.annotation.DrawableRes
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import rx.Completable
|
import rx.Completable
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
abstract class TrackService(val id: Int) {
|
abstract class TrackService(val id: Int) {
|
||||||
|
|
||||||
val preferences: PreferencesHelper by injectLazy()
|
val preferences: PreferencesHelper by injectLazy()
|
||||||
val networkService: NetworkHelper by injectLazy()
|
val networkService: NetworkHelper by injectLazy()
|
||||||
|
|
||||||
open val client: OkHttpClient
|
open val client: OkHttpClient
|
||||||
get() = networkService.client
|
get() = networkService.client
|
||||||
|
|
||||||
// Name of the manga sync service to display
|
// Name of the manga sync service to display
|
||||||
abstract val name: String
|
abstract val name: String
|
||||||
|
|
||||||
@DrawableRes
|
@DrawableRes
|
||||||
abstract fun getLogo(): Int
|
abstract fun getLogo(): Int
|
||||||
|
|
||||||
abstract fun getLogoColor(): Int
|
abstract fun getLogoColor(): Int
|
||||||
|
|
||||||
abstract fun getStatusList(): List<Int>
|
abstract fun getStatusList(): List<Int>
|
||||||
|
|
||||||
abstract fun getStatus(status: Int): String
|
abstract fun getStatus(status: Int): String
|
||||||
|
|
||||||
abstract fun getScoreList(): List<String>
|
abstract fun getScoreList(): List<String>
|
||||||
|
|
||||||
open fun indexToScore(index: Int): Float {
|
open fun indexToScore(index: Int): Float {
|
||||||
return index.toFloat()
|
return index.toFloat()
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract fun displayScore(track: Track): String
|
abstract fun displayScore(track: Track): String
|
||||||
|
|
||||||
abstract fun add(track: Track): Observable<Track>
|
abstract fun add(track: Track): Observable<Track>
|
||||||
|
|
||||||
abstract fun update(track: Track): Observable<Track>
|
abstract fun update(track: Track): Observable<Track>
|
||||||
|
|
||||||
abstract fun bind(track: Track): Observable<Track>
|
abstract fun bind(track: Track): Observable<Track>
|
||||||
|
|
||||||
abstract fun search(query: String): Observable<List<TrackSearch>>
|
abstract fun search(query: String): Observable<List<TrackSearch>>
|
||||||
|
|
||||||
abstract fun refresh(track: Track): Observable<Track>
|
abstract fun refresh(track: Track): Observable<Track>
|
||||||
|
|
||||||
abstract fun login(username: String, password: String): Completable
|
abstract fun login(username: String, password: String): Completable
|
||||||
|
|
||||||
@CallSuper
|
@CallSuper
|
||||||
open fun logout() {
|
open fun logout() {
|
||||||
preferences.setTrackCredentials(this, "", "")
|
preferences.setTrackCredentials(this, "", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
open val isLogged: Boolean
|
open val isLogged: Boolean
|
||||||
get() = !getUsername().isEmpty() &&
|
get() = !getUsername().isEmpty() &&
|
||||||
!getPassword().isEmpty()
|
!getPassword().isEmpty()
|
||||||
|
|
||||||
fun getUsername() = preferences.trackUsername(this)!!
|
fun getUsername() = preferences.trackUsername(this)!!
|
||||||
|
|
||||||
fun getPassword() = preferences.trackPassword(this)!!
|
fun getPassword() = preferences.trackPassword(this)!!
|
||||||
|
|
||||||
fun saveCredentials(username: String, password: String) {
|
fun saveCredentials(username: String, password: String) {
|
||||||
preferences.setTrackCredentials(this, username, password)
|
preferences.setTrackCredentials(this, username, password)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,214 +1,214 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.anilist
|
package eu.kanade.tachiyomi.data.track.anilist
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
import eu.kanade.tachiyomi.data.track.TrackService
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import rx.Completable
|
import rx.Completable
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
class Anilist(private val context: Context, id: Int) : TrackService(id) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val READING = 1
|
const val READING = 1
|
||||||
const val COMPLETED = 2
|
const val COMPLETED = 2
|
||||||
const val ON_HOLD = 3
|
const val ON_HOLD = 3
|
||||||
const val DROPPED = 4
|
const val DROPPED = 4
|
||||||
const val PLANNING = 5
|
const val PLANNING = 5
|
||||||
const val REPEATING = 6
|
const val REPEATING = 6
|
||||||
|
|
||||||
const val DEFAULT_STATUS = READING
|
const val DEFAULT_STATUS = READING
|
||||||
const val DEFAULT_SCORE = 0
|
const val DEFAULT_SCORE = 0
|
||||||
|
|
||||||
const val POINT_100 = "POINT_100"
|
const val POINT_100 = "POINT_100"
|
||||||
const val POINT_10 = "POINT_10"
|
const val POINT_10 = "POINT_10"
|
||||||
const val POINT_10_DECIMAL = "POINT_10_DECIMAL"
|
const val POINT_10_DECIMAL = "POINT_10_DECIMAL"
|
||||||
const val POINT_5 = "POINT_5"
|
const val POINT_5 = "POINT_5"
|
||||||
const val POINT_3 = "POINT_3"
|
const val POINT_3 = "POINT_3"
|
||||||
}
|
}
|
||||||
|
|
||||||
override val name = "AniList"
|
override val name = "AniList"
|
||||||
|
|
||||||
private val gson: Gson by injectLazy()
|
private val gson: Gson by injectLazy()
|
||||||
|
|
||||||
private val interceptor by lazy { AnilistInterceptor(this, getPassword()) }
|
private val interceptor by lazy { AnilistInterceptor(this, getPassword()) }
|
||||||
|
|
||||||
private val api by lazy { AnilistApi(client, interceptor) }
|
private val api by lazy { AnilistApi(client, interceptor) }
|
||||||
|
|
||||||
private val scorePreference = preferences.anilistScoreType()
|
private val scorePreference = preferences.anilistScoreType()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// If the preference is an int from APIv1, logout user to force using APIv2
|
// If the preference is an int from APIv1, logout user to force using APIv2
|
||||||
try {
|
try {
|
||||||
scorePreference.get()
|
scorePreference.get()
|
||||||
} catch (e: ClassCastException) {
|
} catch (e: ClassCastException) {
|
||||||
logout()
|
logout()
|
||||||
scorePreference.delete()
|
scorePreference.delete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLogo() = R.drawable.al
|
override fun getLogo() = R.drawable.al
|
||||||
|
|
||||||
override fun getLogoColor() = Color.rgb(18, 25, 35)
|
override fun getLogoColor() = Color.rgb(18, 25, 35)
|
||||||
|
|
||||||
override fun getStatusList(): List<Int> {
|
override fun getStatusList(): List<Int> {
|
||||||
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING)
|
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getStatus(status: Int): String = with(context) {
|
override fun getStatus(status: Int): String = with(context) {
|
||||||
when (status) {
|
when (status) {
|
||||||
READING -> getString(R.string.reading)
|
READING -> getString(R.string.reading)
|
||||||
COMPLETED -> getString(R.string.completed)
|
COMPLETED -> getString(R.string.completed)
|
||||||
ON_HOLD -> getString(R.string.on_hold)
|
ON_HOLD -> getString(R.string.on_hold)
|
||||||
DROPPED -> getString(R.string.dropped)
|
DROPPED -> getString(R.string.dropped)
|
||||||
PLANNING -> getString(R.string.plan_to_read)
|
PLANNING -> getString(R.string.plan_to_read)
|
||||||
REPEATING -> getString(R.string.repeating)
|
REPEATING -> getString(R.string.repeating)
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getScoreList(): List<String> {
|
override fun getScoreList(): List<String> {
|
||||||
return when (scorePreference.getOrDefault()) {
|
return when (scorePreference.getOrDefault()) {
|
||||||
// 10 point
|
// 10 point
|
||||||
POINT_10 -> IntRange(0, 10).map(Int::toString)
|
POINT_10 -> IntRange(0, 10).map(Int::toString)
|
||||||
// 100 point
|
// 100 point
|
||||||
POINT_100 -> IntRange(0, 100).map(Int::toString)
|
POINT_100 -> IntRange(0, 100).map(Int::toString)
|
||||||
// 5 stars
|
// 5 stars
|
||||||
POINT_5 -> IntRange(0, 5).map { "$it ★" }
|
POINT_5 -> IntRange(0, 5).map { "$it ★" }
|
||||||
// Smiley
|
// Smiley
|
||||||
POINT_3 -> listOf("-", "😦", "😐", "😊")
|
POINT_3 -> listOf("-", "😦", "😐", "😊")
|
||||||
// 10 point decimal
|
// 10 point decimal
|
||||||
POINT_10_DECIMAL -> IntRange(0, 100).map { (it / 10f).toString() }
|
POINT_10_DECIMAL -> IntRange(0, 100).map { (it / 10f).toString() }
|
||||||
else -> throw Exception("Unknown score type")
|
else -> throw Exception("Unknown score type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun indexToScore(index: Int): Float {
|
override fun indexToScore(index: Int): Float {
|
||||||
return when (scorePreference.getOrDefault()) {
|
return when (scorePreference.getOrDefault()) {
|
||||||
// 10 point
|
// 10 point
|
||||||
POINT_10 -> index * 10f
|
POINT_10 -> index * 10f
|
||||||
// 100 point
|
// 100 point
|
||||||
POINT_100 -> index.toFloat()
|
POINT_100 -> index.toFloat()
|
||||||
// 5 stars
|
// 5 stars
|
||||||
POINT_5 -> when {
|
POINT_5 -> when {
|
||||||
index == 0 -> 0f
|
index == 0 -> 0f
|
||||||
else -> index * 20f - 10f
|
else -> index * 20f - 10f
|
||||||
}
|
}
|
||||||
// Smiley
|
// Smiley
|
||||||
POINT_3 -> when {
|
POINT_3 -> when {
|
||||||
index == 0 -> 0f
|
index == 0 -> 0f
|
||||||
else -> index * 25f + 10f
|
else -> index * 25f + 10f
|
||||||
}
|
}
|
||||||
// 10 point decimal
|
// 10 point decimal
|
||||||
POINT_10_DECIMAL -> index.toFloat()
|
POINT_10_DECIMAL -> index.toFloat()
|
||||||
else -> throw Exception("Unknown score type")
|
else -> throw Exception("Unknown score type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun displayScore(track: Track): String {
|
override fun displayScore(track: Track): String {
|
||||||
val score = track.score
|
val score = track.score
|
||||||
|
|
||||||
return when (scorePreference.getOrDefault()) {
|
return when (scorePreference.getOrDefault()) {
|
||||||
POINT_5 -> when {
|
POINT_5 -> when {
|
||||||
score == 0f -> "0 ★"
|
score == 0f -> "0 ★"
|
||||||
else -> "${((score + 10) / 20).toInt()} ★"
|
else -> "${((score + 10) / 20).toInt()} ★"
|
||||||
}
|
}
|
||||||
POINT_3 -> when {
|
POINT_3 -> when {
|
||||||
score == 0f -> "0"
|
score == 0f -> "0"
|
||||||
score <= 35 -> "😦"
|
score <= 35 -> "😦"
|
||||||
score <= 60 -> "😐"
|
score <= 60 -> "😐"
|
||||||
else -> "😊"
|
else -> "😊"
|
||||||
}
|
}
|
||||||
else -> track.toAnilistScore()
|
else -> track.toAnilistScore()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun add(track: Track): Observable<Track> {
|
override fun add(track: Track): Observable<Track> {
|
||||||
return api.addLibManga(track)
|
return api.addLibManga(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun update(track: Track): Observable<Track> {
|
override fun update(track: Track): Observable<Track> {
|
||||||
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
|
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
|
||||||
track.status = COMPLETED
|
track.status = COMPLETED
|
||||||
}
|
}
|
||||||
// If user was using API v1 fetch library_id
|
// If user was using API v1 fetch library_id
|
||||||
if (track.library_id == null || track.library_id!! == 0L){
|
if (track.library_id == null || track.library_id!! == 0L){
|
||||||
return api.findLibManga(track, getUsername().toInt()).flatMap {
|
return api.findLibManga(track, getUsername().toInt()).flatMap {
|
||||||
if (it == null) {
|
if (it == null) {
|
||||||
throw Exception("$track not found on user library")
|
throw Exception("$track not found on user library")
|
||||||
}
|
}
|
||||||
track.library_id = it.library_id
|
track.library_id = it.library_id
|
||||||
api.updateLibManga(track)
|
api.updateLibManga(track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return api.updateLibManga(track)
|
return api.updateLibManga(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun bind(track: Track): Observable<Track> {
|
override fun bind(track: Track): Observable<Track> {
|
||||||
return api.findLibManga(track, getUsername().toInt())
|
return api.findLibManga(track, getUsername().toInt())
|
||||||
.flatMap { remoteTrack ->
|
.flatMap { remoteTrack ->
|
||||||
if (remoteTrack != null) {
|
if (remoteTrack != null) {
|
||||||
track.copyPersonalFrom(remoteTrack)
|
track.copyPersonalFrom(remoteTrack)
|
||||||
track.library_id = remoteTrack.library_id
|
track.library_id = remoteTrack.library_id
|
||||||
update(track)
|
update(track)
|
||||||
} else {
|
} else {
|
||||||
// Set default fields if it's not found in the list
|
// Set default fields if it's not found in the list
|
||||||
track.score = DEFAULT_SCORE.toFloat()
|
track.score = DEFAULT_SCORE.toFloat()
|
||||||
track.status = DEFAULT_STATUS
|
track.status = DEFAULT_STATUS
|
||||||
add(track)
|
add(track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||||
return api.search(query)
|
return api.search(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun refresh(track: Track): Observable<Track> {
|
override fun refresh(track: Track): Observable<Track> {
|
||||||
return api.getLibManga(track, getUsername().toInt())
|
return api.getLibManga(track, getUsername().toInt())
|
||||||
.map { remoteTrack ->
|
.map { remoteTrack ->
|
||||||
track.copyPersonalFrom(remoteTrack)
|
track.copyPersonalFrom(remoteTrack)
|
||||||
track.total_chapters = remoteTrack.total_chapters
|
track.total_chapters = remoteTrack.total_chapters
|
||||||
track
|
track
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun login(username: String, password: String) = login(password)
|
override fun login(username: String, password: String) = login(password)
|
||||||
|
|
||||||
fun login(token: String): Completable {
|
fun login(token: String): Completable {
|
||||||
val oauth = api.createOAuth(token)
|
val oauth = api.createOAuth(token)
|
||||||
interceptor.setAuth(oauth)
|
interceptor.setAuth(oauth)
|
||||||
return api.getCurrentUser().map { (username, scoreType) ->
|
return api.getCurrentUser().map { (username, scoreType) ->
|
||||||
scorePreference.set(scoreType)
|
scorePreference.set(scoreType)
|
||||||
saveCredentials(username.toString(), oauth.access_token)
|
saveCredentials(username.toString(), oauth.access_token)
|
||||||
}.doOnError{
|
}.doOnError{
|
||||||
logout()
|
logout()
|
||||||
}.toCompletable()
|
}.toCompletable()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun logout() {
|
override fun logout() {
|
||||||
super.logout()
|
super.logout()
|
||||||
preferences.trackToken(this).set(null)
|
preferences.trackToken(this).set(null)
|
||||||
interceptor.setAuth(null)
|
interceptor.setAuth(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveOAuth(oAuth: OAuth?) {
|
fun saveOAuth(oAuth: OAuth?) {
|
||||||
preferences.trackToken(this).set(gson.toJson(oAuth))
|
preferences.trackToken(this).set(gson.toJson(oAuth))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadOAuth(): OAuth? {
|
fun loadOAuth(): OAuth? {
|
||||||
return try {
|
return try {
|
||||||
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
|
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,286 +1,286 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.anilist
|
package eu.kanade.tachiyomi.data.track.anilist
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import com.github.salomonbrys.kotson.*
|
import com.github.salomonbrys.kotson.*
|
||||||
import com.google.gson.JsonObject
|
import com.google.gson.JsonObject
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
import okhttp3.MediaType
|
import okhttp3.MediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
|
||||||
|
|
||||||
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|
||||||
|
|
||||||
private val parser = JsonParser()
|
private val parser = JsonParser()
|
||||||
private val jsonMime = MediaType.parse("application/json; charset=utf-8")
|
private val jsonMime = MediaType.parse("application/json; charset=utf-8")
|
||||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||||
|
|
||||||
|
fun addLibManga(track: Track): Observable<Track> {
|
||||||
fun addLibManga(track: Track): Observable<Track> {
|
val query = """
|
||||||
val query = """
|
|mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) {
|
||||||
mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) {
|
|SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) {
|
||||||
SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status)
|
| id
|
||||||
{ id status } }
|
| status
|
||||||
"""
|
|}
|
||||||
val variables = jsonObject(
|
|}
|
||||||
"mangaId" to track.media_id,
|
|""".trimMargin()
|
||||||
"progress" to track.last_chapter_read,
|
val variables = jsonObject(
|
||||||
"status" to track.toAnilistStatus()
|
"mangaId" to track.media_id,
|
||||||
)
|
"progress" to track.last_chapter_read,
|
||||||
val payload = jsonObject(
|
"status" to track.toAnilistStatus()
|
||||||
"query" to query,
|
)
|
||||||
"variables" to variables
|
val payload = jsonObject(
|
||||||
)
|
"query" to query,
|
||||||
val body = RequestBody.create(jsonMime, payload.toString())
|
"variables" to variables
|
||||||
val request = Request.Builder()
|
)
|
||||||
.url(apiUrl)
|
val body = RequestBody.create(jsonMime, payload.toString())
|
||||||
.post(body)
|
val request = Request.Builder()
|
||||||
.build()
|
.url(apiUrl)
|
||||||
return authClient.newCall(request)
|
.post(body)
|
||||||
.asObservableSuccess()
|
.build()
|
||||||
.map { netResponse ->
|
return authClient.newCall(request)
|
||||||
val responseBody = netResponse.body()?.string().orEmpty()
|
.asObservableSuccess()
|
||||||
netResponse.close()
|
.map { netResponse ->
|
||||||
if (responseBody.isEmpty()) {
|
val responseBody = netResponse.body()?.string().orEmpty()
|
||||||
throw Exception("Null Response")
|
netResponse.close()
|
||||||
}
|
if (responseBody.isEmpty()) {
|
||||||
val response = parser.parse(responseBody).obj
|
throw Exception("Null Response")
|
||||||
track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong
|
}
|
||||||
track
|
val response = parser.parse(responseBody).obj
|
||||||
}
|
track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong
|
||||||
}
|
track
|
||||||
|
}
|
||||||
fun updateLibManga(track: Track): Observable<Track> {
|
}
|
||||||
val query = """
|
|
||||||
mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
|
fun updateLibManga(track: Track): Observable<Track> {
|
||||||
SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) {
|
val query = """
|
||||||
id
|
|mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
|
||||||
status
|
|SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) {
|
||||||
progress
|
|id
|
||||||
}
|
|status
|
||||||
}
|
|progress
|
||||||
"""
|
|}
|
||||||
val variables = jsonObject(
|
|}
|
||||||
"listId" to track.library_id,
|
|""".trimMargin()
|
||||||
"progress" to track.last_chapter_read,
|
val variables = jsonObject(
|
||||||
"status" to track.toAnilistStatus(),
|
"listId" to track.library_id,
|
||||||
"score" to track.score.toInt()
|
"progress" to track.last_chapter_read,
|
||||||
)
|
"status" to track.toAnilistStatus(),
|
||||||
val payload = jsonObject(
|
"score" to track.score.toInt()
|
||||||
"query" to query,
|
)
|
||||||
"variables" to variables
|
val payload = jsonObject(
|
||||||
)
|
"query" to query,
|
||||||
val body = RequestBody.create(jsonMime, payload.toString())
|
"variables" to variables
|
||||||
val request = Request.Builder()
|
)
|
||||||
.url(apiUrl)
|
val body = RequestBody.create(jsonMime, payload.toString())
|
||||||
.post(body)
|
val request = Request.Builder()
|
||||||
.build()
|
.url(apiUrl)
|
||||||
return authClient.newCall(request)
|
.post(body)
|
||||||
.asObservableSuccess()
|
.build()
|
||||||
.map {
|
return authClient.newCall(request)
|
||||||
track
|
.asObservableSuccess()
|
||||||
}
|
.map {
|
||||||
}
|
track
|
||||||
|
}
|
||||||
fun search(search: String): Observable<List<TrackSearch>> {
|
}
|
||||||
val query = """
|
|
||||||
query Search(${'$'}query: String) {
|
fun search(search: String): Observable<List<TrackSearch>> {
|
||||||
Page (perPage: 50) {
|
val query = """
|
||||||
media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
|
|query Search(${'$'}query: String) {
|
||||||
id
|
|Page (perPage: 50) {
|
||||||
title {
|
|media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
|
||||||
romaji
|
|id
|
||||||
}
|
|title {
|
||||||
coverImage {
|
|romaji
|
||||||
large
|
|}
|
||||||
}
|
|coverImage {
|
||||||
type
|
|large
|
||||||
status
|
|}
|
||||||
chapters
|
|type
|
||||||
description
|
|status
|
||||||
startDate {
|
|chapters
|
||||||
year
|
|description
|
||||||
month
|
|startDate {
|
||||||
day
|
|year
|
||||||
}
|
|month
|
||||||
}
|
|day
|
||||||
}
|
|}
|
||||||
}
|
|}
|
||||||
"""
|
|}
|
||||||
val variables = jsonObject(
|
|}
|
||||||
"query" to search
|
|""".trimMargin()
|
||||||
)
|
val variables = jsonObject(
|
||||||
val payload = jsonObject(
|
"query" to search
|
||||||
"query" to query,
|
)
|
||||||
"variables" to variables
|
val payload = jsonObject(
|
||||||
)
|
"query" to query,
|
||||||
val body = RequestBody.create(jsonMime, payload.toString())
|
"variables" to variables
|
||||||
val request = Request.Builder()
|
)
|
||||||
.url(apiUrl)
|
val body = RequestBody.create(jsonMime, payload.toString())
|
||||||
.post(body)
|
val request = Request.Builder()
|
||||||
.build()
|
.url(apiUrl)
|
||||||
return authClient.newCall(request)
|
.post(body)
|
||||||
.asObservableSuccess()
|
.build()
|
||||||
.map { netResponse ->
|
return authClient.newCall(request)
|
||||||
val responseBody = netResponse.body()?.string().orEmpty()
|
.asObservableSuccess()
|
||||||
if (responseBody.isEmpty()) {
|
.map { netResponse ->
|
||||||
throw Exception("Null Response")
|
val responseBody = netResponse.body()?.string().orEmpty()
|
||||||
}
|
if (responseBody.isEmpty()) {
|
||||||
val response = parser.parse(responseBody).obj
|
throw Exception("Null Response")
|
||||||
val data = response["data"]!!.obj
|
}
|
||||||
val page = data["Page"].obj
|
val response = parser.parse(responseBody).obj
|
||||||
val media = page["media"].array
|
val data = response["data"]!!.obj
|
||||||
val entries = media.map { jsonToALManga(it.obj) }
|
val page = data["Page"].obj
|
||||||
entries.map { it.toTrack() }
|
val media = page["media"].array
|
||||||
}
|
val entries = media.map { jsonToALManga(it.obj) }
|
||||||
}
|
entries.map { it.toTrack() }
|
||||||
|
}
|
||||||
|
}
|
||||||
fun findLibManga(track: Track, userid: Int) : Observable<Track?> {
|
|
||||||
val query = """
|
|
||||||
query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
|
fun findLibManga(track: Track, userid: Int): Observable<Track?> {
|
||||||
Page {
|
val query = """
|
||||||
mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
|
|query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
|
||||||
id
|
|Page {
|
||||||
status
|
|mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
|
||||||
scoreRaw: score(format: POINT_100)
|
|id
|
||||||
progress
|
|status
|
||||||
media{
|
|scoreRaw: score(format: POINT_100)
|
||||||
id
|
|progress
|
||||||
title {
|
|media {
|
||||||
romaji
|
|id
|
||||||
}
|
|title {
|
||||||
coverImage {
|
|romaji
|
||||||
large
|
|}
|
||||||
}
|
|coverImage {
|
||||||
type
|
|large
|
||||||
status
|
|}
|
||||||
chapters
|
|type
|
||||||
description
|
|status
|
||||||
startDate {
|
|chapters
|
||||||
year
|
|description
|
||||||
month
|
|startDate {
|
||||||
day
|
|year
|
||||||
}
|
|month
|
||||||
}
|
|day
|
||||||
}
|
|}
|
||||||
}
|
|}
|
||||||
}
|
|}
|
||||||
"""
|
|}
|
||||||
val variables = jsonObject(
|
|}
|
||||||
"id" to userid,
|
|""".trimMargin()
|
||||||
"manga_id" to track.media_id
|
val variables = jsonObject(
|
||||||
)
|
"id" to userid,
|
||||||
val payload = jsonObject(
|
"manga_id" to track.media_id
|
||||||
"query" to query,
|
)
|
||||||
"variables" to variables
|
val payload = jsonObject(
|
||||||
)
|
"query" to query,
|
||||||
val body = RequestBody.create(jsonMime, payload.toString())
|
"variables" to variables
|
||||||
val request = Request.Builder()
|
)
|
||||||
.url(apiUrl)
|
val body = RequestBody.create(jsonMime, payload.toString())
|
||||||
.post(body)
|
val request = Request.Builder()
|
||||||
.build()
|
.url(apiUrl)
|
||||||
return authClient.newCall(request)
|
.post(body)
|
||||||
.asObservableSuccess()
|
.build()
|
||||||
.map { netResponse ->
|
return authClient.newCall(request)
|
||||||
val responseBody = netResponse.body()?.string().orEmpty()
|
.asObservableSuccess()
|
||||||
if (responseBody.isEmpty()) {
|
.map { netResponse ->
|
||||||
throw Exception("Null Response")
|
val responseBody = netResponse.body()?.string().orEmpty()
|
||||||
}
|
if (responseBody.isEmpty()) {
|
||||||
val response = parser.parse(responseBody).obj
|
throw Exception("Null Response")
|
||||||
val data = response["data"]!!.obj
|
}
|
||||||
val page = data["Page"].obj
|
val response = parser.parse(responseBody).obj
|
||||||
val media = page["mediaList"].array
|
val data = response["data"]!!.obj
|
||||||
val entries = media.map { jsonToALUserManga(it.obj) }
|
val page = data["Page"].obj
|
||||||
entries.firstOrNull()?.toTrack()
|
val media = page["mediaList"].array
|
||||||
|
val entries = media.map { jsonToALUserManga(it.obj) }
|
||||||
}
|
entries.firstOrNull()?.toTrack()
|
||||||
}
|
|
||||||
|
}
|
||||||
fun getLibManga(track: Track, userid: Int): Observable<Track> {
|
}
|
||||||
return findLibManga(track, userid)
|
|
||||||
.map { it ?: throw Exception("Could not find manga") }
|
fun getLibManga(track: Track, userid: Int): Observable<Track> {
|
||||||
}
|
return findLibManga(track, userid)
|
||||||
|
.map { it ?: throw Exception("Could not find manga") }
|
||||||
fun createOAuth(token: String): OAuth {
|
}
|
||||||
return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
|
|
||||||
}
|
fun createOAuth(token: String): OAuth {
|
||||||
|
return OAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
|
||||||
fun getCurrentUser(): Observable<Pair<Int, String>> {
|
}
|
||||||
val query = """
|
|
||||||
query User
|
fun getCurrentUser(): Observable<Pair<Int, String>> {
|
||||||
{
|
val query = """
|
||||||
Viewer {
|
|query User {
|
||||||
id
|
|Viewer {
|
||||||
mediaListOptions {
|
|id
|
||||||
scoreFormat
|
|mediaListOptions {
|
||||||
}
|
|scoreFormat
|
||||||
}
|
|}
|
||||||
}
|
|}
|
||||||
"""
|
|}
|
||||||
val payload = jsonObject(
|
|""".trimMargin()
|
||||||
"query" to query
|
val payload = jsonObject(
|
||||||
)
|
"query" to query
|
||||||
val body = RequestBody.create(jsonMime, payload.toString())
|
)
|
||||||
val request = Request.Builder()
|
val body = RequestBody.create(jsonMime, payload.toString())
|
||||||
.url(apiUrl)
|
val request = Request.Builder()
|
||||||
.post(body)
|
.url(apiUrl)
|
||||||
.build()
|
.post(body)
|
||||||
return authClient.newCall(request)
|
.build()
|
||||||
.asObservableSuccess()
|
return authClient.newCall(request)
|
||||||
.map { netResponse ->
|
.asObservableSuccess()
|
||||||
val responseBody = netResponse.body()?.string().orEmpty()
|
.map { netResponse ->
|
||||||
if (responseBody.isEmpty()) {
|
val responseBody = netResponse.body()?.string().orEmpty()
|
||||||
throw Exception("Null Response")
|
if (responseBody.isEmpty()) {
|
||||||
}
|
throw Exception("Null Response")
|
||||||
val response = parser.parse(responseBody).obj
|
}
|
||||||
val data = response["data"]!!.obj
|
val response = parser.parse(responseBody).obj
|
||||||
val viewer = data["Viewer"].obj
|
val data = response["data"]!!.obj
|
||||||
Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString)
|
val viewer = data["Viewer"].obj
|
||||||
}
|
Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
fun jsonToALManga(struct: JsonObject): ALManga{
|
|
||||||
val date = try {
|
private fun jsonToALManga(struct: JsonObject): ALManga {
|
||||||
val date = Calendar.getInstance()
|
val date = try {
|
||||||
date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1,
|
val date = Calendar.getInstance()
|
||||||
struct["startDate"]["day"].nullInt ?: 0)
|
date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1,
|
||||||
date.timeInMillis
|
struct["startDate"]["day"].nullInt ?: 0)
|
||||||
} catch (_: Exception) {
|
date.timeInMillis
|
||||||
0L
|
} catch (_: Exception) {
|
||||||
}
|
0L
|
||||||
|
}
|
||||||
return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString,
|
|
||||||
struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString,
|
return ALManga(struct["id"].asInt, struct["title"]["romaji"].asString, struct["coverImage"]["large"].asString,
|
||||||
date, struct["chapters"].nullInt ?: 0)
|
struct["description"].nullString.orEmpty(), struct["type"].asString, struct["status"].asString,
|
||||||
}
|
date, struct["chapters"].nullInt ?: 0)
|
||||||
|
}
|
||||||
fun jsonToALUserManga(struct: JsonObject): ALUserManga{
|
|
||||||
return ALUserManga(struct["id"].asLong, struct["status"].asString, struct["scoreRaw"].asInt, struct["progress"].asInt, jsonToALManga(struct["media"].obj) )
|
private fun jsonToALUserManga(struct: JsonObject): ALUserManga {
|
||||||
}
|
return ALUserManga(struct["id"].asLong, struct["status"].asString, struct["scoreRaw"].asInt, struct["progress"].asInt, jsonToALManga(struct["media"].obj))
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val clientId = "385"
|
private const val clientId = "385"
|
||||||
private const val clientUrl = "tachiyomi://anilist-auth"
|
private const val clientUrl = "tachiyomi://anilist-auth"
|
||||||
private const val apiUrl = "https://graphql.anilist.co/"
|
private const val apiUrl = "https://graphql.anilist.co/"
|
||||||
private const val baseUrl = "https://anilist.co/api/v2/"
|
private const val baseUrl = "https://anilist.co/api/v2/"
|
||||||
private const val baseMangaUrl = "https://anilist.co/manga/"
|
private const val baseMangaUrl = "https://anilist.co/manga/"
|
||||||
|
|
||||||
fun mangaUrl(mediaId: Int): String {
|
fun mangaUrl(mediaId: Int): String {
|
||||||
return baseMangaUrl + mediaId
|
return baseMangaUrl + mediaId
|
||||||
}
|
}
|
||||||
|
|
||||||
fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon()
|
fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon()
|
||||||
.appendQueryParameter("client_id", clientId)
|
.appendQueryParameter("client_id", clientId)
|
||||||
.appendQueryParameter("response_type", "token")
|
.appendQueryParameter("response_type", "token")
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,58 +1,58 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.anilist
|
package eu.kanade.tachiyomi.data.track.anilist
|
||||||
|
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
|
||||||
|
|
||||||
class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor {
|
class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth object used for authenticated requests.
|
* OAuth object used for authenticated requests.
|
||||||
*
|
*
|
||||||
* Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
|
* Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
|
||||||
* before its original expiration date.
|
* before its original expiration date.
|
||||||
*/
|
*/
|
||||||
private var oauth: OAuth? = null
|
private var oauth: OAuth? = null
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
|
field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val originalRequest = chain.request()
|
val originalRequest = chain.request()
|
||||||
|
|
||||||
if (token.isNullOrEmpty()) {
|
if (token.isNullOrEmpty()) {
|
||||||
throw Exception("Not authenticated with Anilist")
|
throw Exception("Not authenticated with Anilist")
|
||||||
}
|
}
|
||||||
if (oauth == null){
|
if (oauth == null){
|
||||||
oauth = anilist.loadOAuth()
|
oauth = anilist.loadOAuth()
|
||||||
}
|
}
|
||||||
// Refresh access token if null or expired.
|
// Refresh access token if null or expired.
|
||||||
if (oauth!!.isExpired()) {
|
if (oauth!!.isExpired()) {
|
||||||
anilist.logout()
|
anilist.logout()
|
||||||
throw Exception("Token expired")
|
throw Exception("Token expired")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throw on null auth.
|
// Throw on null auth.
|
||||||
if (oauth == null) {
|
if (oauth == null) {
|
||||||
throw Exception("No authentication token")
|
throw Exception("No authentication token")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the authorization header to the original request.
|
// Add the authorization header to the original request.
|
||||||
val authRequest = originalRequest.newBuilder()
|
val authRequest = originalRequest.newBuilder()
|
||||||
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
|
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return chain.proceed(authRequest)
|
return chain.proceed(authRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the user authenticates with Anilist for the first time. Sets the refresh token
|
* Called when the user authenticates with Anilist for the first time. Sets the refresh token
|
||||||
* and the oauth object.
|
* and the oauth object.
|
||||||
*/
|
*/
|
||||||
fun setAuth(oauth: OAuth?) {
|
fun setAuth(oauth: OAuth?) {
|
||||||
token = oauth?.access_token
|
token = oauth?.access_token
|
||||||
this.oauth = oauth
|
this.oauth = oauth
|
||||||
anilist.saveOAuth(oauth)
|
anilist.saveOAuth(oauth)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -1,10 +1,10 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.anilist
|
package eu.kanade.tachiyomi.data.track.anilist
|
||||||
|
|
||||||
data class OAuth(
|
data class OAuth(
|
||||||
val access_token: String,
|
val access_token: String,
|
||||||
val token_type: String,
|
val token_type: String,
|
||||||
val expires: Long,
|
val expires: Long,
|
||||||
val expires_in: Long) {
|
val expires_in: Long) {
|
||||||
|
|
||||||
fun isExpired() = System.currentTimeMillis() > expires
|
fun isExpired() = System.currentTimeMillis() > expires
|
||||||
}
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.bangumi
|
||||||
|
|
||||||
|
data class Avatar(
|
||||||
|
val large: String? = "",
|
||||||
|
val medium: String? = "",
|
||||||
|
val small: String? = ""
|
||||||
|
)
|
@ -0,0 +1,144 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.bangumi
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
|
import rx.Completable
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
class Bangumi(private val context: Context, id: Int) : TrackService(id) {
|
||||||
|
|
||||||
|
override fun getScoreList(): List<String> {
|
||||||
|
return IntRange(0, 10).map(Int::toString)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun displayScore(track: Track): String {
|
||||||
|
return track.score.toInt().toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun add(track: Track): Observable<Track> {
|
||||||
|
return api.addLibManga(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun update(track: Track): Observable<Track> {
|
||||||
|
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
|
||||||
|
track.status = COMPLETED
|
||||||
|
}
|
||||||
|
return api.updateLibManga(track)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(track: Track): Observable<Track> {
|
||||||
|
return api.statusLibManga(track)
|
||||||
|
.flatMap {
|
||||||
|
api.findLibManga(track).flatMap { remoteTrack ->
|
||||||
|
if (remoteTrack != null && it != null) {
|
||||||
|
track.copyPersonalFrom(remoteTrack)
|
||||||
|
track.library_id = remoteTrack.library_id
|
||||||
|
track.status = remoteTrack.status
|
||||||
|
track.last_chapter_read = remoteTrack.last_chapter_read
|
||||||
|
refresh(track)
|
||||||
|
} else {
|
||||||
|
// Set default fields if it's not found in the list
|
||||||
|
track.score = DEFAULT_SCORE.toFloat()
|
||||||
|
track.status = DEFAULT_STATUS
|
||||||
|
add(track)
|
||||||
|
update(track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||||
|
return api.search(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun refresh(track: Track): Observable<Track> {
|
||||||
|
return api.statusLibManga(track)
|
||||||
|
.flatMap {
|
||||||
|
track.copyPersonalFrom(it!!)
|
||||||
|
api.findLibManga(track)
|
||||||
|
.map { remoteTrack ->
|
||||||
|
if (remoteTrack != null) {
|
||||||
|
track.total_chapters = remoteTrack.total_chapters
|
||||||
|
track.status = remoteTrack.status
|
||||||
|
}
|
||||||
|
track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val READING = 3
|
||||||
|
const val COMPLETED = 2
|
||||||
|
const val ON_HOLD = 4
|
||||||
|
const val DROPPED = 5
|
||||||
|
const val PLANNING = 1
|
||||||
|
|
||||||
|
const val DEFAULT_STATUS = READING
|
||||||
|
const val DEFAULT_SCORE = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override val name = "Bangumi"
|
||||||
|
|
||||||
|
private val gson: Gson by injectLazy()
|
||||||
|
|
||||||
|
private val interceptor by lazy { BangumiInterceptor(this, gson) }
|
||||||
|
|
||||||
|
private val api by lazy { BangumiApi(client, interceptor) }
|
||||||
|
|
||||||
|
override fun getLogo() = R.drawable.bangumi
|
||||||
|
|
||||||
|
override fun getLogoColor() = Color.rgb(0xF0, 0x91, 0x99)
|
||||||
|
|
||||||
|
override fun getStatusList(): List<Int> {
|
||||||
|
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getStatus(status: Int): String = with(context) {
|
||||||
|
when (status) {
|
||||||
|
READING -> getString(R.string.reading)
|
||||||
|
COMPLETED -> getString(R.string.completed)
|
||||||
|
ON_HOLD -> getString(R.string.on_hold)
|
||||||
|
DROPPED -> getString(R.string.dropped)
|
||||||
|
PLANNING -> getString(R.string.plan_to_read)
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun login(username: String, password: String) = login(password)
|
||||||
|
|
||||||
|
fun login(code: String): Completable {
|
||||||
|
return api.accessToken(code).map { oauth: OAuth? ->
|
||||||
|
interceptor.newAuth(oauth)
|
||||||
|
if (oauth != null) {
|
||||||
|
saveCredentials(oauth.user_id.toString(), oauth.access_token)
|
||||||
|
}
|
||||||
|
}.doOnError {
|
||||||
|
logout()
|
||||||
|
}.toCompletable()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveToken(oauth: OAuth?) {
|
||||||
|
val json = gson.toJson(oauth)
|
||||||
|
preferences.trackToken(this).set(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restoreToken(): OAuth? {
|
||||||
|
return try {
|
||||||
|
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun logout() {
|
||||||
|
super.logout()
|
||||||
|
preferences.trackToken(this).set(null)
|
||||||
|
interceptor.newAuth(null)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,211 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.bangumi
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.github.salomonbrys.kotson.array
|
||||||
|
import com.github.salomonbrys.kotson.obj
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
|
import okhttp3.CacheControl
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
class BangumiApi(private val client: OkHttpClient, interceptor: BangumiInterceptor) {
|
||||||
|
|
||||||
|
private val gson: Gson by injectLazy()
|
||||||
|
private val parser = JsonParser()
|
||||||
|
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||||
|
|
||||||
|
fun addLibManga(track: Track): Observable<Track> {
|
||||||
|
val body = FormBody.Builder()
|
||||||
|
.add("rating", track.score.toInt().toString())
|
||||||
|
.add("status", track.toBangumiStatus())
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$apiUrl/collection/${track.media_id}/update")
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
return authClient.newCall(request)
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map {
|
||||||
|
track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateLibManga(track: Track): Observable<Track> {
|
||||||
|
// chapter update
|
||||||
|
val body = FormBody.Builder()
|
||||||
|
.add("watched_eps", track.last_chapter_read.toString())
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$apiUrl/subject/${track.media_id}/update/watched_eps")
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// read status update
|
||||||
|
val sbody = FormBody.Builder()
|
||||||
|
.add("status", track.toBangumiStatus())
|
||||||
|
.build()
|
||||||
|
val srequest = Request.Builder()
|
||||||
|
.url("$apiUrl/collection/${track.media_id}/update")
|
||||||
|
.post(sbody)
|
||||||
|
.build()
|
||||||
|
return authClient.newCall(srequest)
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map {
|
||||||
|
track
|
||||||
|
}.flatMap {
|
||||||
|
authClient.newCall(request)
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map {
|
||||||
|
track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun search(search: String): Observable<List<TrackSearch>> {
|
||||||
|
val url = Uri.parse(
|
||||||
|
"$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}").buildUpon()
|
||||||
|
.appendQueryParameter("max_results", "20")
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url.toString())
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
return authClient.newCall(request)
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { netResponse ->
|
||||||
|
var responseBody = netResponse.body()?.string().orEmpty()
|
||||||
|
if (responseBody.isEmpty()) {
|
||||||
|
throw Exception("Null Response")
|
||||||
|
}
|
||||||
|
if(responseBody.contains("\"code\":404")){
|
||||||
|
responseBody = "{\"results\":0,\"list\":[]}"
|
||||||
|
}
|
||||||
|
val response = parser.parse(responseBody).obj["list"]?.array
|
||||||
|
response?.filter { it.obj["type"].asInt == 1 }?.map { jsonToSearch(it.obj) }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonToSearch(obj: JsonObject): TrackSearch {
|
||||||
|
return TrackSearch.create(TrackManager.BANGUMI).apply {
|
||||||
|
media_id = obj["id"].asInt
|
||||||
|
title = obj["name_cn"].asString
|
||||||
|
cover_url = obj["images"].obj["common"].asString
|
||||||
|
summary = obj["name"].asString
|
||||||
|
tracking_url = obj["url"].asString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonToTrack(mangas: JsonObject): Track {
|
||||||
|
return Track.create(TrackManager.BANGUMI).apply {
|
||||||
|
title = mangas["name"].asString
|
||||||
|
media_id = mangas["id"].asInt
|
||||||
|
score = if (mangas["rating"] != null)
|
||||||
|
(if (mangas["rating"].isJsonObject) mangas["rating"].obj["score"].asFloat else 0f)
|
||||||
|
else 0f
|
||||||
|
status = Bangumi.DEFAULT_STATUS
|
||||||
|
tracking_url = mangas["url"].asString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findLibManga(track: Track): Observable<Track?> {
|
||||||
|
val urlMangas = "$apiUrl/subject/${track.media_id}"
|
||||||
|
val requestMangas = Request.Builder()
|
||||||
|
.url(urlMangas)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return authClient.newCall(requestMangas)
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { netResponse ->
|
||||||
|
// get comic info
|
||||||
|
val responseBody = netResponse.body()?.string().orEmpty()
|
||||||
|
jsonToTrack(parser.parse(responseBody).obj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun statusLibManga(track: Track): Observable<Track?> {
|
||||||
|
val urlUserRead = "$apiUrl/collection/${track.media_id}"
|
||||||
|
val requestUserRead = Request.Builder()
|
||||||
|
.url(urlUserRead)
|
||||||
|
.cacheControl(CacheControl.FORCE_NETWORK)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// todo get user readed chapter here
|
||||||
|
return authClient.newCall(requestUserRead)
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { netResponse ->
|
||||||
|
val resp = netResponse.body()?.string()
|
||||||
|
val coll = gson.fromJson(resp, Collection::class.java)
|
||||||
|
track.status = coll.status?.id!!
|
||||||
|
track.last_chapter_read = coll.ep_status!!
|
||||||
|
track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun accessToken(code: String): Observable<OAuth> {
|
||||||
|
return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse ->
|
||||||
|
val responseBody = netResponse.body()?.string().orEmpty()
|
||||||
|
if (responseBody.isEmpty()) {
|
||||||
|
throw Exception("Null Response")
|
||||||
|
}
|
||||||
|
gson.fromJson(responseBody, OAuth::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun accessTokenRequest(code: String) = POST(oauthUrl,
|
||||||
|
body = FormBody.Builder()
|
||||||
|
.add("grant_type", "authorization_code")
|
||||||
|
.add("client_id", clientId)
|
||||||
|
.add("client_secret", clientSecret)
|
||||||
|
.add("code", code)
|
||||||
|
.add("redirect_uri", redirectUrl)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val clientId = "bgm10555cda0762e80ca"
|
||||||
|
private const val clientSecret = "8fff394a8627b4c388cbf349ec865775"
|
||||||
|
|
||||||
|
private const val baseUrl = "https://bangumi.org"
|
||||||
|
private const val apiUrl = "https://api.bgm.tv"
|
||||||
|
private const val oauthUrl = "https://bgm.tv/oauth/access_token"
|
||||||
|
private const val loginUrl = "https://bgm.tv/oauth/authorize"
|
||||||
|
|
||||||
|
private const val redirectUrl = "tachiyomi://bangumi-auth"
|
||||||
|
private const val baseMangaUrl = "$apiUrl/mangas"
|
||||||
|
|
||||||
|
fun mangaUrl(remoteId: Int): String {
|
||||||
|
return "$baseMangaUrl/$remoteId"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun authUrl() =
|
||||||
|
Uri.parse(loginUrl).buildUpon()
|
||||||
|
.appendQueryParameter("client_id", clientId)
|
||||||
|
.appendQueryParameter("response_type", "code")
|
||||||
|
.appendQueryParameter("redirect_uri", redirectUrl)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun refreshTokenRequest(token: String) = POST(oauthUrl,
|
||||||
|
body = FormBody.Builder()
|
||||||
|
.add("grant_type", "refresh_token")
|
||||||
|
.add("client_id", clientId)
|
||||||
|
.add("client_secret", clientSecret)
|
||||||
|
.add("refresh_token", token)
|
||||||
|
.add("redirect_uri", redirectUrl)
|
||||||
|
.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,61 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.bangumi
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
class BangumiInterceptor(val bangumi: Bangumi, val gson: Gson) : Interceptor {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth object used for authenticated requests.
|
||||||
|
*/
|
||||||
|
private var oauth: OAuth? = bangumi.restoreToken()
|
||||||
|
|
||||||
|
fun addTocken(tocken: String, oidFormBody: FormBody): FormBody {
|
||||||
|
val newFormBody = FormBody.Builder()
|
||||||
|
for (i in 0 until oidFormBody.size()) {
|
||||||
|
newFormBody.add(oidFormBody.name(i), oidFormBody.value(i))
|
||||||
|
}
|
||||||
|
newFormBody.add("access_token", tocken)
|
||||||
|
return newFormBody.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val originalRequest = chain.request()
|
||||||
|
|
||||||
|
val currAuth = oauth ?: throw Exception("Not authenticated with Bangumi")
|
||||||
|
|
||||||
|
if (currAuth.isExpired()) {
|
||||||
|
val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refresh_token!!))
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
newAuth(gson.fromJson(response.body()!!.string(), OAuth::class.java))
|
||||||
|
} else {
|
||||||
|
response.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var authRequest = if (originalRequest.method() == "GET") originalRequest.newBuilder()
|
||||||
|
.header("User-Agent", "Tachiyomi")
|
||||||
|
.url(originalRequest.url().newBuilder()
|
||||||
|
.addQueryParameter("access_token", currAuth.access_token).build())
|
||||||
|
.build() else originalRequest.newBuilder()
|
||||||
|
.post(addTocken(currAuth.access_token, originalRequest.body() as FormBody))
|
||||||
|
.header("User-Agent", "Tachiyomi")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return chain.proceed(authRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun newAuth(oauth: OAuth?) {
|
||||||
|
this.oauth = if (oauth == null) null else OAuth(
|
||||||
|
oauth.access_token,
|
||||||
|
oauth.token_type,
|
||||||
|
System.currentTimeMillis() / 1000,
|
||||||
|
oauth.expires_in,
|
||||||
|
oauth.refresh_token,
|
||||||
|
this.oauth?.user_id)
|
||||||
|
|
||||||
|
bangumi.saveToken(oauth)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.bangumi
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
|
|
||||||
|
fun Track.toBangumiStatus() = when (status) {
|
||||||
|
Bangumi.READING -> "do"
|
||||||
|
Bangumi.COMPLETED -> "collect"
|
||||||
|
Bangumi.ON_HOLD -> "on_hold"
|
||||||
|
Bangumi.DROPPED -> "dropped"
|
||||||
|
Bangumi.PLANNING -> "wish"
|
||||||
|
else -> throw NotImplementedError("Unknown status")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toTrackStatus(status: String) = when (status) {
|
||||||
|
"do" -> Bangumi.READING
|
||||||
|
"collect" -> Bangumi.COMPLETED
|
||||||
|
"on_hold" -> Bangumi.ON_HOLD
|
||||||
|
"dropped" -> Bangumi.DROPPED
|
||||||
|
"wish" -> Bangumi.PLANNING
|
||||||
|
|
||||||
|
else -> throw Exception("Unknown status")
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.bangumi
|
||||||
|
|
||||||
|
data class Collection(
|
||||||
|
val `private`: Int? = 0,
|
||||||
|
val comment: String? = "",
|
||||||
|
val ep_status: Int? = 0,
|
||||||
|
val lasttouch: Int? = 0,
|
||||||
|
val rating: Int? = 0,
|
||||||
|
val status: Status? = Status(),
|
||||||
|
val tag: List<String?>? = listOf(),
|
||||||
|
val user: User? = User(),
|
||||||
|
val vol_status: Int? = 0
|
||||||
|
)
|
@ -0,0 +1,16 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.bangumi
|
||||||
|
|
||||||
|
data class OAuth(
|
||||||
|
val access_token: String,
|
||||||
|
val token_type: String,
|
||||||
|
val created_at: Long,
|
||||||
|
val expires_in: Long,
|
||||||
|
val refresh_token: String?,
|
||||||
|
val user_id: Long?
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Access token refersh before expired
|
||||||
|
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.bangumi
|
||||||
|
|
||||||
|
data class Status(
|
||||||
|
val id: Int? = 0,
|
||||||
|
val name: String? = "",
|
||||||
|
val type: String? = ""
|
||||||
|
)
|
@ -0,0 +1,11 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.bangumi
|
||||||
|
|
||||||
|
data class User(
|
||||||
|
val avatar: Avatar? = Avatar(),
|
||||||
|
val id: Int? = 0,
|
||||||
|
val nickname: String? = "",
|
||||||
|
val sign: String? = "",
|
||||||
|
val url: String? = "",
|
||||||
|
val usergroup: Int? = 0,
|
||||||
|
val username: String? = ""
|
||||||
|
)
|
@ -1,144 +1,144 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.kitsu
|
package eu.kanade.tachiyomi.data.track.kitsu
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.track.TrackService
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import rx.Completable
|
import rx.Completable
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.text.DecimalFormat
|
import java.text.DecimalFormat
|
||||||
|
|
||||||
class Kitsu(private val context: Context, id: Int) : TrackService(id) {
|
class Kitsu(private val context: Context, id: Int) : TrackService(id) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val READING = 1
|
const val READING = 1
|
||||||
const val COMPLETED = 2
|
const val COMPLETED = 2
|
||||||
const val ON_HOLD = 3
|
const val ON_HOLD = 3
|
||||||
const val DROPPED = 4
|
const val DROPPED = 4
|
||||||
const val PLAN_TO_READ = 5
|
const val PLAN_TO_READ = 5
|
||||||
|
|
||||||
const val DEFAULT_STATUS = READING
|
const val DEFAULT_STATUS = READING
|
||||||
const val DEFAULT_SCORE = 0f
|
const val DEFAULT_SCORE = 0f
|
||||||
}
|
}
|
||||||
|
|
||||||
override val name = "Kitsu"
|
override val name = "Kitsu"
|
||||||
|
|
||||||
private val gson: Gson by injectLazy()
|
private val gson: Gson by injectLazy()
|
||||||
|
|
||||||
private val interceptor by lazy { KitsuInterceptor(this, gson) }
|
private val interceptor by lazy { KitsuInterceptor(this, gson) }
|
||||||
|
|
||||||
private val api by lazy { KitsuApi(client, interceptor) }
|
private val api by lazy { KitsuApi(client, interceptor) }
|
||||||
|
|
||||||
override fun getLogo(): Int {
|
override fun getLogo(): Int {
|
||||||
return R.drawable.kitsu
|
return R.drawable.kitsu
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getLogoColor(): Int {
|
override fun getLogoColor(): Int {
|
||||||
return Color.rgb(51, 37, 50)
|
return Color.rgb(51, 37, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getStatusList(): List<Int> {
|
override fun getStatusList(): List<Int> {
|
||||||
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
|
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getStatus(status: Int): String = with(context) {
|
override fun getStatus(status: Int): String = with(context) {
|
||||||
when (status) {
|
when (status) {
|
||||||
READING -> getString(R.string.reading)
|
READING -> getString(R.string.reading)
|
||||||
COMPLETED -> getString(R.string.completed)
|
COMPLETED -> getString(R.string.completed)
|
||||||
ON_HOLD -> getString(R.string.on_hold)
|
ON_HOLD -> getString(R.string.on_hold)
|
||||||
DROPPED -> getString(R.string.dropped)
|
DROPPED -> getString(R.string.dropped)
|
||||||
PLAN_TO_READ -> getString(R.string.plan_to_read)
|
PLAN_TO_READ -> getString(R.string.plan_to_read)
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getScoreList(): List<String> {
|
override fun getScoreList(): List<String> {
|
||||||
val df = DecimalFormat("0.#")
|
val df = DecimalFormat("0.#")
|
||||||
return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) }
|
return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun indexToScore(index: Int): Float {
|
override fun indexToScore(index: Int): Float {
|
||||||
return if (index > 0) (index + 1) / 2f else 0f
|
return if (index > 0) (index + 1) / 2f else 0f
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun displayScore(track: Track): String {
|
override fun displayScore(track: Track): String {
|
||||||
val df = DecimalFormat("0.#")
|
val df = DecimalFormat("0.#")
|
||||||
return df.format(track.score)
|
return df.format(track.score)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun add(track: Track): Observable<Track> {
|
override fun add(track: Track): Observable<Track> {
|
||||||
return api.addLibManga(track, getUserId())
|
return api.addLibManga(track, getUserId())
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun update(track: Track): Observable<Track> {
|
override fun update(track: Track): Observable<Track> {
|
||||||
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
|
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
|
||||||
track.status = COMPLETED
|
track.status = COMPLETED
|
||||||
}
|
}
|
||||||
|
|
||||||
return api.updateLibManga(track)
|
return api.updateLibManga(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun bind(track: Track): Observable<Track> {
|
override fun bind(track: Track): Observable<Track> {
|
||||||
return api.findLibManga(track, getUserId())
|
return api.findLibManga(track, getUserId())
|
||||||
.flatMap { remoteTrack ->
|
.flatMap { remoteTrack ->
|
||||||
if (remoteTrack != null) {
|
if (remoteTrack != null) {
|
||||||
track.copyPersonalFrom(remoteTrack)
|
track.copyPersonalFrom(remoteTrack)
|
||||||
track.media_id = remoteTrack.media_id
|
track.media_id = remoteTrack.media_id
|
||||||
update(track)
|
update(track)
|
||||||
} else {
|
} else {
|
||||||
track.score = DEFAULT_SCORE
|
track.score = DEFAULT_SCORE
|
||||||
track.status = DEFAULT_STATUS
|
track.status = DEFAULT_STATUS
|
||||||
add(track)
|
add(track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||||
return api.search(query)
|
return api.search(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun refresh(track: Track): Observable<Track> {
|
override fun refresh(track: Track): Observable<Track> {
|
||||||
return api.getLibManga(track)
|
return api.getLibManga(track)
|
||||||
.map { remoteTrack ->
|
.map { remoteTrack ->
|
||||||
track.copyPersonalFrom(remoteTrack)
|
track.copyPersonalFrom(remoteTrack)
|
||||||
track.total_chapters = remoteTrack.total_chapters
|
track.total_chapters = remoteTrack.total_chapters
|
||||||
track
|
track
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun login(username: String, password: String): Completable {
|
override fun login(username: String, password: String): Completable {
|
||||||
return api.login(username, password)
|
return api.login(username, password)
|
||||||
.doOnNext { interceptor.newAuth(it) }
|
.doOnNext { interceptor.newAuth(it) }
|
||||||
.flatMap { api.getCurrentUser() }
|
.flatMap { api.getCurrentUser() }
|
||||||
.doOnNext { userId -> saveCredentials(username, userId) }
|
.doOnNext { userId -> saveCredentials(username, userId) }
|
||||||
.doOnError { logout() }
|
.doOnError { logout() }
|
||||||
.toCompletable()
|
.toCompletable()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun logout() {
|
override fun logout() {
|
||||||
super.logout()
|
super.logout()
|
||||||
interceptor.newAuth(null)
|
interceptor.newAuth(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getUserId(): String {
|
private fun getUserId(): String {
|
||||||
return getPassword()
|
return getPassword()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveToken(oauth: OAuth?) {
|
fun saveToken(oauth: OAuth?) {
|
||||||
val json = gson.toJson(oauth)
|
val json = gson.toJson(oauth)
|
||||||
preferences.trackToken(this).set(json)
|
preferences.trackToken(this).set(json)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun restoreToken(): OAuth? {
|
fun restoreToken(): OAuth? {
|
||||||
return try {
|
return try {
|
||||||
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
|
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -18,13 +18,12 @@ class KitsuSearchManga(obj: JsonObject) {
|
|||||||
private val synopsis by obj.byString
|
private val synopsis by obj.byString
|
||||||
private var startDate = obj.get("startDate").nullString?.let {
|
private var startDate = obj.get("startDate").nullString?.let {
|
||||||
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
outputDf.format(Date(it!!.toLong() * 1000))
|
outputDf.format(Date(it.toLong() * 1000))
|
||||||
}
|
}
|
||||||
private val endDate = obj.get("endDate").nullString
|
private val endDate = obj.get("endDate").nullString
|
||||||
|
|
||||||
|
|
||||||
@CallSuper
|
@CallSuper
|
||||||
open fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
|
fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
|
||||||
media_id = this@KitsuSearchManga.id
|
media_id = this@KitsuSearchManga.id
|
||||||
title = canonicalTitle
|
title = canonicalTitle
|
||||||
total_chapters = chapterCount ?: 0
|
total_chapters = chapterCount ?: 0
|
||||||
@ -55,7 +54,7 @@ class KitsuLibManga(obj: JsonObject, manga: JsonObject) {
|
|||||||
private val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString
|
private val ratingTwenty = obj["attributes"].obj.get("ratingTwenty").nullString
|
||||||
val progress by obj["attributes"].byInt
|
val progress by obj["attributes"].byInt
|
||||||
|
|
||||||
open fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
|
fun toTrack() = TrackSearch.create(TrackManager.KITSU).apply {
|
||||||
media_id = libraryId
|
media_id = libraryId
|
||||||
title = canonicalTitle
|
title = canonicalTitle
|
||||||
total_chapters = chapterCount ?: 0
|
total_chapters = chapterCount ?: 0
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.kitsu
|
package eu.kanade.tachiyomi.data.track.kitsu
|
||||||
|
|
||||||
data class OAuth(
|
data class OAuth(
|
||||||
val access_token: String,
|
val access_token: String,
|
||||||
val token_type: String,
|
val token_type: String,
|
||||||
val created_at: Long,
|
val created_at: Long,
|
||||||
val expires_in: Long,
|
val expires_in: Long,
|
||||||
val refresh_token: String?) {
|
val refresh_token: String?) {
|
||||||
|
|
||||||
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
|
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
|
||||||
}
|
}
|
@ -1,141 +1,164 @@
|
|||||||
package eu.kanade.tachiyomi.data.track.myanimelist
|
package eu.kanade.tachiyomi.data.track.myanimelist
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.data.database.models.Track
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
import eu.kanade.tachiyomi.data.preference.getOrDefault
|
||||||
import eu.kanade.tachiyomi.data.track.TrackService
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
import rx.Completable
|
import okhttp3.HttpUrl
|
||||||
import rx.Observable
|
import rx.Completable
|
||||||
import java.net.URI
|
import rx.Observable
|
||||||
|
import java.lang.Exception
|
||||||
class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
|
||||||
|
class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
|
||||||
companion object {
|
|
||||||
|
companion object {
|
||||||
const val READING = 1
|
const val READING = 1
|
||||||
const val COMPLETED = 2
|
const val COMPLETED = 2
|
||||||
const val ON_HOLD = 3
|
const val ON_HOLD = 3
|
||||||
const val DROPPED = 4
|
const val DROPPED = 4
|
||||||
const val PLAN_TO_READ = 6
|
const val PLAN_TO_READ = 6
|
||||||
|
|
||||||
const val DEFAULT_STATUS = READING
|
const val DEFAULT_STATUS = READING
|
||||||
const val DEFAULT_SCORE = 0
|
const val DEFAULT_SCORE = 0
|
||||||
|
|
||||||
const val BASE_URL = "https://myanimelist.net"
|
const val BASE_URL = "https://myanimelist.net"
|
||||||
const val USER_SESSION_COOKIE = "MALSESSIONID"
|
const val USER_SESSION_COOKIE = "MALSESSIONID"
|
||||||
const val LOGGED_IN_COOKIE = "is_logged_in"
|
const val LOGGED_IN_COOKIE = "is_logged_in"
|
||||||
}
|
}
|
||||||
|
|
||||||
private val api by lazy { MyanimelistApi(client) }
|
private val interceptor by lazy { MyAnimeListInterceptor(this) }
|
||||||
|
private val api by lazy { MyanimelistApi(client, interceptor) }
|
||||||
override val name: String
|
|
||||||
get() = "MyAnimeList"
|
override val name: String
|
||||||
|
get() = "MyAnimeList"
|
||||||
override fun getLogo() = R.drawable.mal
|
|
||||||
|
override fun getLogo() = R.drawable.mal
|
||||||
override fun getLogoColor() = Color.rgb(46, 81, 162)
|
|
||||||
|
override fun getLogoColor() = Color.rgb(46, 81, 162)
|
||||||
override fun getStatus(status: Int): String = with(context) {
|
|
||||||
when (status) {
|
override fun getStatus(status: Int): String = with(context) {
|
||||||
READING -> getString(R.string.reading)
|
when (status) {
|
||||||
COMPLETED -> getString(R.string.completed)
|
READING -> getString(R.string.reading)
|
||||||
ON_HOLD -> getString(R.string.on_hold)
|
COMPLETED -> getString(R.string.completed)
|
||||||
DROPPED -> getString(R.string.dropped)
|
ON_HOLD -> getString(R.string.on_hold)
|
||||||
PLAN_TO_READ -> getString(R.string.plan_to_read)
|
DROPPED -> getString(R.string.dropped)
|
||||||
else -> ""
|
PLAN_TO_READ -> getString(R.string.plan_to_read)
|
||||||
}
|
else -> ""
|
||||||
}
|
}
|
||||||
|
}
|
||||||
override fun getStatusList(): List<Int> {
|
|
||||||
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
|
override fun getStatusList(): List<Int> {
|
||||||
}
|
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ)
|
||||||
|
}
|
||||||
override fun getScoreList(): List<String> {
|
|
||||||
return IntRange(0, 10).map(Int::toString)
|
override fun getScoreList(): List<String> {
|
||||||
}
|
return IntRange(0, 10).map(Int::toString)
|
||||||
|
}
|
||||||
override fun displayScore(track: Track): String {
|
|
||||||
return track.score.toInt().toString()
|
override fun displayScore(track: Track): String {
|
||||||
}
|
return track.score.toInt().toString()
|
||||||
|
}
|
||||||
override fun add(track: Track): Observable<Track> {
|
|
||||||
return api.addLibManga(track, getCSRF())
|
override fun add(track: Track): Observable<Track> {
|
||||||
}
|
return api.addLibManga(track)
|
||||||
|
}
|
||||||
override fun update(track: Track): Observable<Track> {
|
|
||||||
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
|
override fun update(track: Track): Observable<Track> {
|
||||||
track.status = COMPLETED
|
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
|
||||||
}
|
track.status = COMPLETED
|
||||||
|
}
|
||||||
return api.updateLibManga(track, getCSRF())
|
|
||||||
}
|
return api.updateLibManga(track)
|
||||||
|
}
|
||||||
override fun bind(track: Track): Observable<Track> {
|
|
||||||
return api.findLibManga(track, getCSRF())
|
override fun bind(track: Track): Observable<Track> {
|
||||||
.flatMap { remoteTrack ->
|
return api.findLibManga(track)
|
||||||
if (remoteTrack != null) {
|
.flatMap { remoteTrack ->
|
||||||
track.copyPersonalFrom(remoteTrack)
|
if (remoteTrack != null) {
|
||||||
update(track)
|
track.copyPersonalFrom(remoteTrack)
|
||||||
} else {
|
update(track)
|
||||||
// Set default fields if it's not found in the list
|
} else {
|
||||||
track.score = DEFAULT_SCORE.toFloat()
|
// Set default fields if it's not found in the list
|
||||||
track.status = DEFAULT_STATUS
|
track.score = DEFAULT_SCORE.toFloat()
|
||||||
add(track)
|
track.status = DEFAULT_STATUS
|
||||||
}
|
add(track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
override fun search(query: String): Observable<List<TrackSearch>> {
|
|
||||||
return api.search(query)
|
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||||
}
|
return api.search(query)
|
||||||
|
}
|
||||||
override fun refresh(track: Track): Observable<Track> {
|
|
||||||
return api.getLibManga(track, getCSRF())
|
override fun refresh(track: Track): Observable<Track> {
|
||||||
.map { remoteTrack ->
|
return api.getLibManga(track)
|
||||||
track.copyPersonalFrom(remoteTrack)
|
.map { remoteTrack ->
|
||||||
track.total_chapters = remoteTrack.total_chapters
|
track.copyPersonalFrom(remoteTrack)
|
||||||
track
|
track.total_chapters = remoteTrack.total_chapters
|
||||||
}
|
track
|
||||||
}
|
}
|
||||||
|
}
|
||||||
override fun login(username: String, password: String): Completable {
|
|
||||||
logout()
|
override fun login(username: String, password: String): Completable {
|
||||||
|
logout()
|
||||||
return api.login(username, password)
|
|
||||||
.doOnNext { csrf -> saveCSRF(csrf) }
|
return Observable.fromCallable { api.login(username, password) }
|
||||||
.doOnNext { saveCredentials(username, password) }
|
.doOnNext { csrf -> saveCSRF(csrf) }
|
||||||
.doOnError { logout() }
|
.doOnNext { saveCredentials(username, password) }
|
||||||
.toCompletable()
|
.doOnError { logout() }
|
||||||
}
|
.toCompletable()
|
||||||
|
}
|
||||||
override fun logout() {
|
|
||||||
super.logout()
|
fun refreshLogin() {
|
||||||
preferences.trackToken(this).delete()
|
val username = getUsername()
|
||||||
networkService.cookies.remove(URI(BASE_URL))
|
val password = getPassword()
|
||||||
}
|
logout()
|
||||||
|
|
||||||
override val isLogged: Boolean
|
try {
|
||||||
get() = !getUsername().isEmpty() &&
|
val csrf = api.login(username, password)
|
||||||
!getPassword().isEmpty() &&
|
saveCSRF(csrf)
|
||||||
checkCookies(URI(BASE_URL)) &&
|
saveCredentials(username, password)
|
||||||
!getCSRF().isEmpty()
|
} catch (e: Exception) {
|
||||||
|
logout()
|
||||||
private fun getCSRF(): String = preferences.trackToken(this).getOrDefault()
|
throw e
|
||||||
|
}
|
||||||
private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf)
|
}
|
||||||
|
|
||||||
private fun checkCookies(uri: URI): Boolean {
|
// Attempt to login again if cookies have been cleared but credentials are still filled
|
||||||
var ckCount = 0
|
fun ensureLoggedIn() {
|
||||||
|
if (isAuthorized) return
|
||||||
for (ck in networkService.cookies.get(uri)) {
|
if (!isLogged) throw Exception("MAL Login Credentials not found")
|
||||||
if (ck.name() == USER_SESSION_COOKIE || ck.name() == LOGGED_IN_COOKIE)
|
|
||||||
ckCount++
|
refreshLogin()
|
||||||
}
|
}
|
||||||
|
|
||||||
return ckCount == 2
|
override fun logout() {
|
||||||
}
|
super.logout()
|
||||||
|
preferences.trackToken(this).delete()
|
||||||
}
|
networkService.cookieManager.remove(HttpUrl.parse(BASE_URL)!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
val isAuthorized: Boolean
|
||||||
|
get() = super.isLogged &&
|
||||||
|
getCSRF().isNotEmpty() &&
|
||||||
|
checkCookies()
|
||||||
|
|
||||||
|
fun getCSRF(): String = preferences.trackToken(this).getOrDefault()
|
||||||
|
|
||||||
|
private fun saveCSRF(csrf: String) = preferences.trackToken(this).set(csrf)
|
||||||
|
|
||||||
|
private fun checkCookies(): Boolean {
|
||||||
|
var ckCount = 0
|
||||||
|
val url = HttpUrl.parse(BASE_URL)!!
|
||||||
|
for (ck in networkService.cookieManager.get(url)) {
|
||||||
|
if (ck.name() == USER_SESSION_COOKIE || ck.name() == LOGGED_IN_COOKIE)
|
||||||
|
ckCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
return ckCount == 2
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
@ -0,0 +1,59 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.myanimelist
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import okio.Buffer
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class MyAnimeListInterceptor(private val myanimelist: Myanimelist): Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
myanimelist.ensureLoggedIn()
|
||||||
|
|
||||||
|
val request = chain.request()
|
||||||
|
var response = chain.proceed(updateRequest(request))
|
||||||
|
|
||||||
|
if (response.code() == 400){
|
||||||
|
myanimelist.refreshLogin()
|
||||||
|
response = chain.proceed(updateRequest(request))
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRequest(request: Request): Request {
|
||||||
|
return request.body()?.let {
|
||||||
|
val contentType = it.contentType().toString()
|
||||||
|
val updatedBody = when {
|
||||||
|
contentType.contains("x-www-form-urlencoded") -> updateFormBody(it)
|
||||||
|
contentType.contains("json") -> updateJsonBody(it)
|
||||||
|
else -> it
|
||||||
|
}
|
||||||
|
request.newBuilder().post(updatedBody).build()
|
||||||
|
} ?: request
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bodyToString(requestBody: RequestBody): String {
|
||||||
|
Buffer().use {
|
||||||
|
requestBody.writeTo(it)
|
||||||
|
return it.readUtf8()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateFormBody(requestBody: RequestBody): RequestBody {
|
||||||
|
val formString = bodyToString(requestBody)
|
||||||
|
|
||||||
|
return RequestBody.create(requestBody.contentType(),
|
||||||
|
"$formString${if (formString.isNotEmpty()) "&" else ""}${MyanimelistApi.CSRF}=${myanimelist.getCSRF()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateJsonBody(requestBody: RequestBody): RequestBody {
|
||||||
|
val jsonString = bodyToString(requestBody)
|
||||||
|
val newBody = JSONObject(jsonString)
|
||||||
|
.put(MyanimelistApi.CSRF, myanimelist.getCSRF())
|
||||||
|
|
||||||
|
return RequestBody.create(requestBody.contentType(), newBody.toString())
|
||||||
|
}
|
||||||
|
}
|
@ -22,61 +22,122 @@ import java.io.InputStreamReader
|
|||||||
import java.util.zip.GZIPInputStream
|
import java.util.zip.GZIPInputStream
|
||||||
|
|
||||||
|
|
||||||
class MyanimelistApi(private val client: OkHttpClient) {
|
class MyanimelistApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) {
|
||||||
|
|
||||||
fun addLibManga(track: Track, csrf: String): Observable<Track> {
|
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||||
return Observable.defer {
|
|
||||||
client.newCall(POST(url = getAddUrl(), body = getMangaPostPayload(track, csrf)))
|
|
||||||
.asObservableSuccess()
|
|
||||||
.map { track }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateLibManga(track: Track, csrf: String): Observable<Track> {
|
|
||||||
return Observable.defer {
|
|
||||||
client.newCall(POST(url = getUpdateUrl(), body = getMangaPostPayload(track, csrf)))
|
|
||||||
.asObservableSuccess()
|
|
||||||
.map { track }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun search(query: String): Observable<List<TrackSearch>> {
|
fun search(query: String): Observable<List<TrackSearch>> {
|
||||||
return client.newCall(GET(getSearchUrl(query)))
|
return if (query.startsWith(PREFIX_MY)) {
|
||||||
.asObservable()
|
val realQuery = query.removePrefix(PREFIX_MY)
|
||||||
.flatMap { response ->
|
getList()
|
||||||
Observable.from(Jsoup.parse(response.consumeBody())
|
.flatMap { Observable.from(it) }
|
||||||
.select("div.js-categories-seasonal.js-block-list.list")
|
.filter { it.title.contains(realQuery, true) }
|
||||||
.select("table").select("tbody")
|
.toList()
|
||||||
.select("tr").drop(1))
|
}
|
||||||
}
|
else {
|
||||||
.filter { row ->
|
client.newCall(GET(searchUrl(query)))
|
||||||
row.select(TD)[2].text() != "Novel"
|
.asObservable()
|
||||||
}
|
.flatMap { response ->
|
||||||
.map { row ->
|
Observable.from(Jsoup.parse(response.consumeBody())
|
||||||
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
.select("div.js-categories-seasonal.js-block-list.list")
|
||||||
title = row.searchTitle()
|
.select("table").select("tbody")
|
||||||
media_id = row.searchMediaId()
|
.select("tr").drop(1))
|
||||||
total_chapters = row.searchTotalChapters()
|
|
||||||
summary = row.searchSummary()
|
|
||||||
cover_url = row.searchCoverUrl()
|
|
||||||
tracking_url = mangaUrl(media_id)
|
|
||||||
publishing_status = row.searchPublishingStatus()
|
|
||||||
publishing_type = row.searchPublishingType()
|
|
||||||
start_date = row.searchStartDate()
|
|
||||||
}
|
}
|
||||||
}
|
.filter { row ->
|
||||||
.toList()
|
row.select(TD)[2].text() != "Novel"
|
||||||
|
}
|
||||||
|
.map { row ->
|
||||||
|
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
||||||
|
title = row.searchTitle()
|
||||||
|
media_id = row.searchMediaId()
|
||||||
|
total_chapters = row.searchTotalChapters()
|
||||||
|
summary = row.searchSummary()
|
||||||
|
cover_url = row.searchCoverUrl()
|
||||||
|
tracking_url = mangaUrl(media_id)
|
||||||
|
publishing_status = row.searchPublishingStatus()
|
||||||
|
publishing_type = row.searchPublishingType()
|
||||||
|
start_date = row.searchStartDate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getList(csrf: String): Observable<List<TrackSearch>> {
|
fun addLibManga(track: Track): Observable<Track> {
|
||||||
return getListUrl(csrf)
|
return Observable.defer {
|
||||||
|
authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track)))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { track }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateLibManga(track: Track): Observable<Track> {
|
||||||
|
return Observable.defer {
|
||||||
|
authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track)))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { track }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findLibManga(track: Track): Observable<Track?> {
|
||||||
|
return authClient.newCall(GET(url = listEntryUrl(track.media_id)))
|
||||||
|
.asObservable()
|
||||||
|
.map {response ->
|
||||||
|
var libTrack: Track? = null
|
||||||
|
response.use {
|
||||||
|
if (it.priorResponse()?.isRedirect != true) {
|
||||||
|
val trackForm = Jsoup.parse(it.consumeBody())
|
||||||
|
|
||||||
|
libTrack = Track.create(TrackManager.MYANIMELIST).apply {
|
||||||
|
last_chapter_read = trackForm.select("#add_manga_num_read_chapters").`val`().toInt()
|
||||||
|
total_chapters = trackForm.select("#totalChap").text().toInt()
|
||||||
|
status = trackForm.select("#add_manga_status > option[selected]").`val`().toInt()
|
||||||
|
score = trackForm.select("#add_manga_score > option[selected]").`val`().toFloatOrNull() ?: 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
libTrack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLibManga(track: Track): Observable<Track> {
|
||||||
|
return findLibManga(track)
|
||||||
|
.map { it ?: throw Exception("Could not find manga") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login(username: String, password: String): String {
|
||||||
|
val csrf = getSessionInfo()
|
||||||
|
|
||||||
|
login(username, password, csrf)
|
||||||
|
|
||||||
|
return csrf
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSessionInfo(): String {
|
||||||
|
val response = client.newCall(GET(loginUrl())).execute()
|
||||||
|
|
||||||
|
return Jsoup.parse(response.consumeBody())
|
||||||
|
.select("meta[name=csrf_token]")
|
||||||
|
.attr("content")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun login(username: String, password: String, csrf: String) {
|
||||||
|
val response = client.newCall(POST(url = loginUrl(), body = loginPostBody(username, password, csrf))).execute()
|
||||||
|
|
||||||
|
response.use {
|
||||||
|
if (response.priorResponse()?.code() != 302) throw Exception("Authentication error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getList(): Observable<List<TrackSearch>> {
|
||||||
|
return getListUrl()
|
||||||
.flatMap { url ->
|
.flatMap { url ->
|
||||||
getListXml(url)
|
getListXml(url)
|
||||||
}
|
}
|
||||||
.flatMap { doc ->
|
.flatMap { doc ->
|
||||||
Observable.from(doc.select("manga"))
|
Observable.from(doc.select("manga"))
|
||||||
}
|
}
|
||||||
.map { it ->
|
.map {
|
||||||
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
TrackSearch.create(TrackManager.MYANIMELIST).apply {
|
||||||
title = it.selectText("manga_title")!!
|
title = it.selectText("manga_title")!!
|
||||||
media_id = it.selectInt("manga_mangadb_id")
|
media_id = it.selectInt("manga_mangadb_id")
|
||||||
@ -90,107 +151,8 @@ class MyanimelistApi(private val client: OkHttpClient) {
|
|||||||
.toList()
|
.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getListXml(url: String): Observable<Document> {
|
private fun getListUrl(): Observable<String> {
|
||||||
return client.newCall(GET(url))
|
return authClient.newCall(POST(url = exportListUrl(), body = exportPostBody()))
|
||||||
.asObservable()
|
|
||||||
.map { response ->
|
|
||||||
Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun findLibManga(track: Track, csrf: String): Observable<Track?> {
|
|
||||||
return getList(csrf)
|
|
||||||
.map { list -> list.find { it.media_id == track.media_id } }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLibManga(track: Track, csrf: String): Observable<Track> {
|
|
||||||
return findLibManga(track, csrf)
|
|
||||||
.map { it ?: throw Exception("Could not find manga") }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun login(username: String, password: String): Observable<String> {
|
|
||||||
return getSessionInfo()
|
|
||||||
.flatMap { csrf ->
|
|
||||||
login(username, password, csrf)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getSessionInfo(): Observable<String> {
|
|
||||||
return client.newCall(GET(getLoginUrl()))
|
|
||||||
.asObservable()
|
|
||||||
.map { response ->
|
|
||||||
Jsoup.parse(response.consumeBody())
|
|
||||||
.select("meta[name=csrf_token]")
|
|
||||||
.attr("content")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun login(username: String, password: String, csrf: String): Observable<String> {
|
|
||||||
return client.newCall(POST(url = getLoginUrl(), body = getLoginPostBody(username, password, csrf)))
|
|
||||||
.asObservable()
|
|
||||||
.map { response ->
|
|
||||||
response.use {
|
|
||||||
if (response.priorResponse()?.code() != 302) throw Exception("Authentication error")
|
|
||||||
}
|
|
||||||
csrf
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getLoginPostBody(username: String, password: String, csrf: String): RequestBody {
|
|
||||||
return FormBody.Builder()
|
|
||||||
.add("user_name", username)
|
|
||||||
.add("password", password)
|
|
||||||
.add("cookie", "1")
|
|
||||||
.add("sublogin", "Login")
|
|
||||||
.add("submit", "1")
|
|
||||||
.add(CSRF, csrf)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getExportPostBody(csrf: String): RequestBody {
|
|
||||||
return FormBody.Builder()
|
|
||||||
.add("type", "2")
|
|
||||||
.add("subexport", "Export My List")
|
|
||||||
.add(CSRF, csrf)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getMangaPostPayload(track: Track, csrf: String): RequestBody {
|
|
||||||
val body = JSONObject()
|
|
||||||
.put("manga_id", track.media_id)
|
|
||||||
.put("status", track.status)
|
|
||||||
.put("score", track.score)
|
|
||||||
.put("num_read_chapters", track.last_chapter_read)
|
|
||||||
.put(CSRF, csrf)
|
|
||||||
|
|
||||||
return RequestBody.create(MediaType.parse("application/json; charset=utf-8"), body.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getLoginUrl() = Uri.parse(baseUrl).buildUpon()
|
|
||||||
.appendPath("login.php")
|
|
||||||
.toString()
|
|
||||||
|
|
||||||
private fun getSearchUrl(query: String): String {
|
|
||||||
val col = "c[]"
|
|
||||||
return Uri.parse(baseUrl).buildUpon()
|
|
||||||
.appendPath("manga.php")
|
|
||||||
.appendQueryParameter("q", query)
|
|
||||||
.appendQueryParameter(col, "a")
|
|
||||||
.appendQueryParameter(col, "b")
|
|
||||||
.appendQueryParameter(col, "c")
|
|
||||||
.appendQueryParameter(col, "d")
|
|
||||||
.appendQueryParameter(col, "e")
|
|
||||||
.appendQueryParameter(col, "g")
|
|
||||||
.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getExportListUrl() = Uri.parse(baseUrl).buildUpon()
|
|
||||||
.appendPath("panel.php")
|
|
||||||
.appendQueryParameter("go", "export")
|
|
||||||
.toString()
|
|
||||||
|
|
||||||
private fun getListUrl(csrf: String): Observable<String> {
|
|
||||||
return client.newCall(POST(url = getExportListUrl(), body = getExportPostBody(csrf)))
|
|
||||||
.asObservable()
|
.asObservable()
|
||||||
.map {response ->
|
.map {response ->
|
||||||
baseUrl + Jsoup.parse(response.consumeBody())
|
baseUrl + Jsoup.parse(response.consumeBody())
|
||||||
@ -200,17 +162,17 @@ class MyanimelistApi(private val client: OkHttpClient) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getUpdateUrl() = Uri.parse(baseModifyListUrl).buildUpon()
|
private fun getListXml(url: String): Observable<Document> {
|
||||||
.appendPath("edit.json")
|
return authClient.newCall(GET(url))
|
||||||
.toString()
|
.asObservable()
|
||||||
|
.map { response ->
|
||||||
|
Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun getAddUrl() = Uri.parse(baseModifyListUrl).buildUpon()
|
|
||||||
.appendPath( "add.json")
|
|
||||||
.toString()
|
|
||||||
|
|
||||||
private fun Response.consumeBody(): String? {
|
private fun Response.consumeBody(): String? {
|
||||||
use {
|
use {
|
||||||
if (it.code() != 200) throw Exception("Login error")
|
if (it.code() != 200) throw Exception("HTTP error ${it.code()}")
|
||||||
return it.body()?.string()
|
return it.body()?.string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -229,37 +191,105 @@ class MyanimelistApi(private val client: OkHttpClient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val baseUrl = "https://myanimelist.net"
|
const val CSRF = "csrf_token"
|
||||||
|
|
||||||
|
private const val baseUrl = "https://myanimelist.net"
|
||||||
private const val baseMangaUrl = "$baseUrl/manga/"
|
private const val baseMangaUrl = "$baseUrl/manga/"
|
||||||
private const val baseModifyListUrl = "$baseUrl/ownlist/manga"
|
private const val baseModifyListUrl = "$baseUrl/ownlist/manga"
|
||||||
|
private const val PREFIX_MY = "my:"
|
||||||
|
private const val TD = "td"
|
||||||
|
|
||||||
fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
|
private fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
|
||||||
|
|
||||||
fun Element.searchTitle() = select("strong").text()!!
|
private fun loginUrl() = Uri.parse(baseUrl).buildUpon()
|
||||||
|
.appendPath("login.php")
|
||||||
|
.toString()
|
||||||
|
|
||||||
fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt()
|
private fun searchUrl(query: String): String {
|
||||||
|
val col = "c[]"
|
||||||
|
return Uri.parse(baseUrl).buildUpon()
|
||||||
|
.appendPath("manga.php")
|
||||||
|
.appendQueryParameter("q", query)
|
||||||
|
.appendQueryParameter(col, "a")
|
||||||
|
.appendQueryParameter(col, "b")
|
||||||
|
.appendQueryParameter(col, "c")
|
||||||
|
.appendQueryParameter(col, "d")
|
||||||
|
.appendQueryParameter(col, "e")
|
||||||
|
.appendQueryParameter(col, "g")
|
||||||
|
.toString()
|
||||||
|
}
|
||||||
|
|
||||||
fun Element.searchCoverUrl() = select("img")
|
private fun exportListUrl() = Uri.parse(baseUrl).buildUpon()
|
||||||
|
.appendPath("panel.php")
|
||||||
|
.appendQueryParameter("go", "export")
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
private fun updateUrl() = Uri.parse(baseModifyListUrl).buildUpon()
|
||||||
|
.appendPath("edit.json")
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
private fun addUrl() = Uri.parse(baseModifyListUrl).buildUpon()
|
||||||
|
.appendPath( "add.json")
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
private fun listEntryUrl(mediaId: Int) = Uri.parse(baseModifyListUrl).buildUpon()
|
||||||
|
.appendPath(mediaId.toString())
|
||||||
|
.appendPath("edit")
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
private fun loginPostBody(username: String, password: String, csrf: String): RequestBody {
|
||||||
|
return FormBody.Builder()
|
||||||
|
.add("user_name", username)
|
||||||
|
.add("password", password)
|
||||||
|
.add("cookie", "1")
|
||||||
|
.add("sublogin", "Login")
|
||||||
|
.add("submit", "1")
|
||||||
|
.add(CSRF, csrf)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exportPostBody(): RequestBody {
|
||||||
|
return FormBody.Builder()
|
||||||
|
.add("type", "2")
|
||||||
|
.add("subexport", "Export My List")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mangaPostPayload(track: Track): RequestBody {
|
||||||
|
val body = JSONObject()
|
||||||
|
.put("manga_id", track.media_id)
|
||||||
|
.put("status", track.status)
|
||||||
|
.put("score", track.score)
|
||||||
|
.put("num_read_chapters", track.last_chapter_read)
|
||||||
|
|
||||||
|
return RequestBody.create(MediaType.parse("application/json; charset=utf-8"), body.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Element.searchTitle() = select("strong").text()!!
|
||||||
|
|
||||||
|
private fun Element.searchTotalChapters() = if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt()
|
||||||
|
|
||||||
|
private fun Element.searchCoverUrl() = select("img")
|
||||||
.attr("data-src")
|
.attr("data-src")
|
||||||
.split("\\?")[0]
|
.split("\\?")[0]
|
||||||
.replace("/r/50x70/", "/")
|
.replace("/r/50x70/", "/")
|
||||||
|
|
||||||
fun Element.searchMediaId() = select("div.picSurround")
|
private fun Element.searchMediaId() = select("div.picSurround")
|
||||||
.select("a").attr("id")
|
.select("a").attr("id")
|
||||||
.replace("sarea", "")
|
.replace("sarea", "")
|
||||||
.toInt()
|
.toInt()
|
||||||
|
|
||||||
fun Element.searchSummary() = select("div.pt4")
|
private fun Element.searchSummary() = select("div.pt4")
|
||||||
.first()
|
.first()
|
||||||
.ownText()!!
|
.ownText()!!
|
||||||
|
|
||||||
fun Element.searchPublishingStatus() = if (select(TD).last().text() == "-") PUBLISHING else FINISHED
|
private fun Element.searchPublishingStatus() = if (select(TD).last().text() == "-") "Publishing" else "Finished"
|
||||||
|
|
||||||
fun Element.searchPublishingType() = select(TD)[2].text()!!
|
private fun Element.searchPublishingType() = select(TD)[2].text()!!
|
||||||
|
|
||||||
fun Element.searchStartDate() = select(TD)[6].text()!!
|
private fun Element.searchStartDate() = select(TD)[6].text()!!
|
||||||
|
|
||||||
fun getStatus(status: String) = when (status) {
|
private fun getStatus(status: String) = when (status) {
|
||||||
"Reading" -> 1
|
"Reading" -> 1
|
||||||
"Completed" -> 2
|
"Completed" -> 2
|
||||||
"On-Hold" -> 3
|
"On-Hold" -> 3
|
||||||
@ -267,10 +297,5 @@ class MyanimelistApi(private val client: OkHttpClient) {
|
|||||||
"Plan to Read" -> 6
|
"Plan to Read" -> 6
|
||||||
else -> 1
|
else -> 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const val CSRF = "csrf_token"
|
|
||||||
const val TD = "td"
|
|
||||||
private const val FINISHED = "Finished"
|
|
||||||
private const val PUBLISHING = "Publishing"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.shikimori
|
||||||
|
|
||||||
|
data class OAuth(
|
||||||
|
val access_token: String,
|
||||||
|
val token_type: String,
|
||||||
|
val created_at: Long,
|
||||||
|
val expires_in: Long,
|
||||||
|
val refresh_token: String?) {
|
||||||
|
|
||||||
|
// Access token lives 1 day
|
||||||
|
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.shikimori
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import eu.kanade.tachiyomi.R
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
|
import rx.Completable
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
class Shikimori(private val context: Context, id: Int) : TrackService(id) {
|
||||||
|
|
||||||
|
override fun getScoreList(): List<String> {
|
||||||
|
return IntRange(0, 10).map(Int::toString)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun displayScore(track: Track): String {
|
||||||
|
return track.score.toInt().toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun add(track: Track): Observable<Track> {
|
||||||
|
return api.addLibManga(track, getUsername())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun update(track: Track): Observable<Track> {
|
||||||
|
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
|
||||||
|
track.status = COMPLETED
|
||||||
|
}
|
||||||
|
return api.updateLibManga(track, getUsername())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(track: Track): Observable<Track> {
|
||||||
|
return api.findLibManga(track, getUsername())
|
||||||
|
.flatMap { remoteTrack ->
|
||||||
|
if (remoteTrack != null) {
|
||||||
|
track.copyPersonalFrom(remoteTrack)
|
||||||
|
track.library_id = remoteTrack.library_id
|
||||||
|
update(track)
|
||||||
|
} else {
|
||||||
|
// Set default fields if it's not found in the list
|
||||||
|
track.score = DEFAULT_SCORE.toFloat()
|
||||||
|
track.status = DEFAULT_STATUS
|
||||||
|
add(track)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun search(query: String): Observable<List<TrackSearch>> {
|
||||||
|
return api.search(query)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun refresh(track: Track): Observable<Track> {
|
||||||
|
return api.findLibManga(track, getUsername())
|
||||||
|
.map { remoteTrack ->
|
||||||
|
if (remoteTrack != null) {
|
||||||
|
track.copyPersonalFrom(remoteTrack)
|
||||||
|
track.total_chapters = remoteTrack.total_chapters
|
||||||
|
}
|
||||||
|
track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val READING = 1
|
||||||
|
const val COMPLETED = 2
|
||||||
|
const val ON_HOLD = 3
|
||||||
|
const val DROPPED = 4
|
||||||
|
const val PLANNING = 5
|
||||||
|
const val REPEATING = 6
|
||||||
|
|
||||||
|
const val DEFAULT_STATUS = READING
|
||||||
|
const val DEFAULT_SCORE = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override val name = "Shikimori"
|
||||||
|
|
||||||
|
private val gson: Gson by injectLazy()
|
||||||
|
|
||||||
|
private val interceptor by lazy { ShikimoriInterceptor(this, gson) }
|
||||||
|
|
||||||
|
private val api by lazy { ShikimoriApi(client, interceptor) }
|
||||||
|
|
||||||
|
override fun getLogo() = R.drawable.shikimori
|
||||||
|
|
||||||
|
override fun getLogoColor() = Color.rgb(40, 40, 40)
|
||||||
|
|
||||||
|
override fun getStatusList(): List<Int> {
|
||||||
|
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getStatus(status: Int): String = with(context) {
|
||||||
|
when (status) {
|
||||||
|
READING -> getString(R.string.reading)
|
||||||
|
COMPLETED -> getString(R.string.completed)
|
||||||
|
ON_HOLD -> getString(R.string.on_hold)
|
||||||
|
DROPPED -> getString(R.string.dropped)
|
||||||
|
PLANNING -> getString(R.string.plan_to_read)
|
||||||
|
REPEATING -> getString(R.string.repeating)
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun login(username: String, password: String) = login(password)
|
||||||
|
|
||||||
|
fun login(code: String): Completable {
|
||||||
|
return api.accessToken(code).map { oauth: OAuth? ->
|
||||||
|
interceptor.newAuth(oauth)
|
||||||
|
if (oauth != null) {
|
||||||
|
val user = api.getCurrentUser()
|
||||||
|
saveCredentials(user.toString(), oauth.access_token)
|
||||||
|
}
|
||||||
|
}.doOnError {
|
||||||
|
logout()
|
||||||
|
}.toCompletable()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveToken(oauth: OAuth?) {
|
||||||
|
val json = gson.toJson(oauth)
|
||||||
|
preferences.trackToken(this).set(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restoreToken(): OAuth? {
|
||||||
|
return try {
|
||||||
|
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun logout() {
|
||||||
|
super.logout()
|
||||||
|
preferences.trackToken(this).set(null)
|
||||||
|
interceptor.newAuth(null)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,205 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.shikimori
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.github.salomonbrys.kotson.array
|
||||||
|
import com.github.salomonbrys.kotson.jsonObject
|
||||||
|
import com.github.salomonbrys.kotson.nullString
|
||||||
|
import com.github.salomonbrys.kotson.obj
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.JsonObject
|
||||||
|
import com.google.gson.JsonParser
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
|
import eu.kanade.tachiyomi.data.track.model.TrackSearch
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
|
import okhttp3.*
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) {
|
||||||
|
|
||||||
|
private val gson: Gson by injectLazy()
|
||||||
|
private val parser = JsonParser()
|
||||||
|
private val jsonime = MediaType.parse("application/json; charset=utf-8")
|
||||||
|
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||||
|
|
||||||
|
fun addLibManga(track: Track, user_id: String): Observable<Track> {
|
||||||
|
val payload = jsonObject(
|
||||||
|
"user_rate" to jsonObject(
|
||||||
|
"user_id" to user_id,
|
||||||
|
"target_id" to track.media_id,
|
||||||
|
"target_type" to "Manga",
|
||||||
|
"chapters" to track.last_chapter_read,
|
||||||
|
"score" to track.score.toInt(),
|
||||||
|
"status" to track.toShikimoriStatus()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val body = RequestBody.create(jsonime, payload.toString())
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$apiUrl/v2/user_rates")
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
return authClient.newCall(request)
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map {
|
||||||
|
track
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateLibManga(track: Track, user_id: String): Observable<Track> = addLibManga(track, user_id)
|
||||||
|
|
||||||
|
fun search(search: String): Observable<List<TrackSearch>> {
|
||||||
|
val url = Uri.parse("$apiUrl/mangas").buildUpon()
|
||||||
|
.appendQueryParameter("order", "popularity")
|
||||||
|
.appendQueryParameter("search", search)
|
||||||
|
.appendQueryParameter("limit", "20")
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url.toString())
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
return authClient.newCall(request)
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { netResponse ->
|
||||||
|
val responseBody = netResponse.body()?.string().orEmpty()
|
||||||
|
if (responseBody.isEmpty()) {
|
||||||
|
throw Exception("Null Response")
|
||||||
|
}
|
||||||
|
val response = parser.parse(responseBody).array
|
||||||
|
response.map { jsonToSearch(it.obj) }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonToSearch(obj: JsonObject): TrackSearch {
|
||||||
|
return TrackSearch.create(TrackManager.SHIKIMORI).apply {
|
||||||
|
media_id = obj["id"].asInt
|
||||||
|
title = obj["name"].asString
|
||||||
|
total_chapters = obj["chapters"].asInt
|
||||||
|
cover_url = baseUrl + obj["image"].obj["preview"].asString
|
||||||
|
summary = ""
|
||||||
|
tracking_url = baseUrl + obj["url"].asString
|
||||||
|
publishing_status = obj["status"].asString
|
||||||
|
publishing_type = obj["kind"].asString
|
||||||
|
start_date = obj.get("aired_on").nullString.orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonToTrack(obj: JsonObject, mangas: JsonObject): Track {
|
||||||
|
return Track.create(TrackManager.SHIKIMORI).apply {
|
||||||
|
title = mangas["name"].asString
|
||||||
|
media_id = obj["id"].asInt
|
||||||
|
total_chapters = mangas["chapters"].asInt
|
||||||
|
last_chapter_read = obj["chapters"].asInt
|
||||||
|
score = (obj["score"].asInt).toFloat()
|
||||||
|
status = toTrackStatus(obj["status"].asString)
|
||||||
|
tracking_url = baseUrl + mangas["url"].asString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findLibManga(track: Track, user_id: String): Observable<Track?> {
|
||||||
|
val url = Uri.parse("$apiUrl/v2/user_rates").buildUpon()
|
||||||
|
.appendQueryParameter("user_id", user_id)
|
||||||
|
.appendQueryParameter("target_id", track.media_id.toString())
|
||||||
|
.appendQueryParameter("target_type", "Manga")
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url.toString())
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val urlMangas = Uri.parse("$apiUrl/mangas").buildUpon()
|
||||||
|
.appendPath(track.media_id.toString())
|
||||||
|
.build()
|
||||||
|
val requestMangas = Request.Builder()
|
||||||
|
.url(urlMangas.toString())
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
return authClient.newCall(requestMangas)
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { netResponse ->
|
||||||
|
val responseBody = netResponse.body()?.string().orEmpty()
|
||||||
|
parser.parse(responseBody).obj
|
||||||
|
}.flatMap { mangas ->
|
||||||
|
authClient.newCall(request)
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { netResponse ->
|
||||||
|
val responseBody = netResponse.body()?.string().orEmpty()
|
||||||
|
if (responseBody.isEmpty()) {
|
||||||
|
throw Exception("Null Response")
|
||||||
|
}
|
||||||
|
val response = parser.parse(responseBody).array
|
||||||
|
if (response.size() > 1) {
|
||||||
|
throw Exception("Too much mangas in response")
|
||||||
|
}
|
||||||
|
val entry = response.map {
|
||||||
|
jsonToTrack(it.obj, mangas)
|
||||||
|
}
|
||||||
|
entry.firstOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentUser(): Int {
|
||||||
|
val user = authClient.newCall(GET("$apiUrl/users/whoami")).execute().body()?.string()
|
||||||
|
return parser.parse(user).obj["id"].asInt
|
||||||
|
}
|
||||||
|
|
||||||
|
fun accessToken(code: String): Observable<OAuth> {
|
||||||
|
return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse ->
|
||||||
|
val responseBody = netResponse.body()?.string().orEmpty()
|
||||||
|
if (responseBody.isEmpty()) {
|
||||||
|
throw Exception("Null Response")
|
||||||
|
}
|
||||||
|
gson.fromJson(responseBody, OAuth::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun accessTokenRequest(code: String) = POST(oauthUrl,
|
||||||
|
body = FormBody.Builder()
|
||||||
|
.add("grant_type", "authorization_code")
|
||||||
|
.add("client_id", clientId)
|
||||||
|
.add("client_secret", clientSecret)
|
||||||
|
.add("code", code)
|
||||||
|
.add("redirect_uri", redirectUrl)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val clientId = "1aaf4cf232372708e98b5abc813d795b539c5a916dbbfe9ac61bf02a360832cc"
|
||||||
|
private const val clientSecret = "229942c742dd4cde803125d17d64501d91c0b12e14cb1e5120184d77d67024c0"
|
||||||
|
|
||||||
|
private const val baseUrl = "https://shikimori.one"
|
||||||
|
private const val apiUrl = "https://shikimori.one/api"
|
||||||
|
private const val oauthUrl = "https://shikimori.one/oauth/token"
|
||||||
|
private const val loginUrl = "https://shikimori.one/oauth/authorize"
|
||||||
|
|
||||||
|
private const val redirectUrl = "tachiyomi://shikimori-auth"
|
||||||
|
private const val baseMangaUrl = "$apiUrl/mangas"
|
||||||
|
|
||||||
|
fun mangaUrl(remoteId: Int): String {
|
||||||
|
return "$baseMangaUrl/$remoteId"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun authUrl() =
|
||||||
|
Uri.parse(loginUrl).buildUpon()
|
||||||
|
.appendQueryParameter("client_id", clientId)
|
||||||
|
.appendQueryParameter("redirect_uri", redirectUrl)
|
||||||
|
.appendQueryParameter("response_type", "code")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
|
||||||
|
fun refreshTokenRequest(token: String) = POST(oauthUrl,
|
||||||
|
body = FormBody.Builder()
|
||||||
|
.add("grant_type", "refresh_token")
|
||||||
|
.add("client_id", clientId)
|
||||||
|
.add("client_secret", clientSecret)
|
||||||
|
.add("refresh_token", token)
|
||||||
|
.build())
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,43 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.shikimori
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
class ShikimoriInterceptor(val shikimori: Shikimori, val gson: Gson) : Interceptor {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth object used for authenticated requests.
|
||||||
|
*/
|
||||||
|
private var oauth: OAuth? = shikimori.restoreToken()
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val originalRequest = chain.request()
|
||||||
|
|
||||||
|
val currAuth = oauth ?: throw Exception("Not authenticated with Shikimori")
|
||||||
|
|
||||||
|
val refreshToken = currAuth.refresh_token!!
|
||||||
|
|
||||||
|
// Refresh access token if expired.
|
||||||
|
if (currAuth.isExpired()) {
|
||||||
|
val response = chain.proceed(ShikimoriApi.refreshTokenRequest(refreshToken))
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
newAuth(gson.fromJson(response.body()!!.string(), OAuth::class.java))
|
||||||
|
} else {
|
||||||
|
response.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add the authorization header to the original request.
|
||||||
|
val authRequest = originalRequest.newBuilder()
|
||||||
|
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
|
||||||
|
.header("User-Agent", "Tachiyomi")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return chain.proceed(authRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun newAuth(oauth: OAuth?) {
|
||||||
|
this.oauth = oauth
|
||||||
|
shikimori.saveToken(oauth)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.track.shikimori
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.database.models.Track
|
||||||
|
|
||||||
|
fun Track.toShikimoriStatus() = when (status) {
|
||||||
|
Shikimori.READING -> "watching"
|
||||||
|
Shikimori.COMPLETED -> "completed"
|
||||||
|
Shikimori.ON_HOLD -> "on_hold"
|
||||||
|
Shikimori.DROPPED -> "dropped"
|
||||||
|
Shikimori.PLANNING -> "planned"
|
||||||
|
Shikimori.REPEATING -> "rewatching"
|
||||||
|
else -> throw NotImplementedError("Unknown status")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toTrackStatus(status: String) = when (status) {
|
||||||
|
"watching" -> Shikimori.READING
|
||||||
|
"completed" -> Shikimori.COMPLETED
|
||||||
|
"on_hold" -> Shikimori.ON_HOLD
|
||||||
|
"dropped" -> Shikimori.DROPPED
|
||||||
|
"planned" -> Shikimori.PLANNING
|
||||||
|
"rewatching" -> Shikimori.REPEATING
|
||||||
|
|
||||||
|
else -> throw Exception("Unknown status")
|
||||||
|
}
|
@ -1,7 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.data.updater
|
|
||||||
|
|
||||||
sealed class GithubUpdateResult {
|
|
||||||
|
|
||||||
class NewUpdate(val release: GithubRelease): GithubUpdateResult()
|
|
||||||
class NoNewUpdate : GithubUpdateResult()
|
|
||||||
}
|
|
@ -0,0 +1,13 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.updater
|
||||||
|
|
||||||
|
interface Release {
|
||||||
|
|
||||||
|
val info: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get download link of latest release.
|
||||||
|
* @return download link of latest release.
|
||||||
|
*/
|
||||||
|
val downloadLink: String
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.updater
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
|
import eu.kanade.tachiyomi.data.updater.devrepo.DevRepoUpdateChecker
|
||||||
|
import eu.kanade.tachiyomi.data.updater.github.GithubUpdateChecker
|
||||||
|
import rx.Observable
|
||||||
|
|
||||||
|
abstract class UpdateChecker {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun getUpdateChecker(): UpdateChecker {
|
||||||
|
return if (BuildConfig.DEBUG) {
|
||||||
|
DevRepoUpdateChecker()
|
||||||
|
} else {
|
||||||
|
GithubUpdateChecker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns observable containing release information
|
||||||
|
*/
|
||||||
|
abstract fun checkForUpdate(): Observable<UpdateResult>
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.updater
|
||||||
|
|
||||||
|
abstract class UpdateResult {
|
||||||
|
|
||||||
|
open class NewUpdate<T : Release>(val release: T): UpdateResult()
|
||||||
|
open class NoNewUpdate: UpdateResult()
|
||||||
|
|
||||||
|
}
|
@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.updater
|
|||||||
|
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import android.support.v4.app.NotificationCompat
|
import android.support.v4.app.NotificationCompat
|
||||||
import com.evernote.android.job.Job
|
import com.evernote.android.job.Job
|
||||||
import com.evernote.android.job.JobManager
|
import com.evernote.android.job.JobManager
|
||||||
@ -13,10 +14,15 @@ import eu.kanade.tachiyomi.util.notificationManager
|
|||||||
class UpdaterJob : Job() {
|
class UpdaterJob : Job() {
|
||||||
|
|
||||||
override fun onRunJob(params: Params): Result {
|
override fun onRunJob(params: Params): Result {
|
||||||
return GithubUpdateChecker()
|
// Android 4.x is no longer supported
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
return Result.SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
return UpdateChecker.getUpdateChecker()
|
||||||
.checkForUpdate()
|
.checkForUpdate()
|
||||||
.map { result ->
|
.map { result ->
|
||||||
if (result is GithubUpdateResult.NewUpdate) {
|
if (result is UpdateResult.NewUpdate<*>) {
|
||||||
val url = result.release.downloadLink
|
val url = result.release.downloadLink
|
||||||
|
|
||||||
val intent = Intent(context, UpdaterService::class.java).apply {
|
val intent = Intent(context, UpdaterService::class.java).apply {
|
||||||
@ -33,9 +39,9 @@ class UpdaterJob : Job() {
|
|||||||
PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Job.Result.SUCCESS
|
Result.SUCCESS
|
||||||
}
|
}
|
||||||
.onErrorReturn { Job.Result.FAILURE }
|
.onErrorReturn { Result.FAILURE }
|
||||||
// Sadly, the task needs to be synchronous.
|
// Sadly, the task needs to be synchronous.
|
||||||
.toBlocking()
|
.toBlocking()
|
||||||
.single()
|
.single()
|
||||||
@ -64,4 +70,4 @@ class UpdaterJob : Job() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,14 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.updater.devrepo
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.updater.Release
|
||||||
|
|
||||||
|
class DevRepoRelease(override val info: String) : Release {
|
||||||
|
|
||||||
|
override val downloadLink: String
|
||||||
|
get() = LATEST_URL
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val LATEST_URL = "https://tachiyomi.kanade.eu/latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.updater.devrepo
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
|
import eu.kanade.tachiyomi.data.updater.UpdateChecker
|
||||||
|
import eu.kanade.tachiyomi.data.updater.UpdateResult
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
|
import eu.kanade.tachiyomi.network.asObservable
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
class DevRepoUpdateChecker : UpdateChecker() {
|
||||||
|
|
||||||
|
private val client: OkHttpClient by lazy {
|
||||||
|
Injekt.get<NetworkHelper>().client.newBuilder()
|
||||||
|
.followRedirects(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val versionRegex: Regex by lazy {
|
||||||
|
Regex("tachiyomi-r(\\d+).apk")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkForUpdate(): Observable<UpdateResult> {
|
||||||
|
return client.newCall(GET(DevRepoRelease.LATEST_URL)).asObservable()
|
||||||
|
.map { response ->
|
||||||
|
// Get latest repo version number from header in format "Location: tachiyomi-r1512.apk"
|
||||||
|
val latestVersionNumber: String = versionRegex.find(response.header("Location")!!)!!.groupValues[1]
|
||||||
|
|
||||||
|
if (latestVersionNumber.toInt() > BuildConfig.COMMIT_COUNT.toInt()) {
|
||||||
|
DevRepoUpdateResult.NewUpdate(DevRepoRelease("v$latestVersionNumber"))
|
||||||
|
} else {
|
||||||
|
DevRepoUpdateResult.NoNewUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.updater.devrepo
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.updater.UpdateResult
|
||||||
|
|
||||||
|
sealed class DevRepoUpdateResult : UpdateResult() {
|
||||||
|
|
||||||
|
class NewUpdate(release: DevRepoRelease): UpdateResult.NewUpdate<DevRepoRelease>(release)
|
||||||
|
class NoNewUpdate: UpdateResult.NoNewUpdate()
|
||||||
|
|
||||||
|
}
|
@ -1,24 +1,25 @@
|
|||||||
package eu.kanade.tachiyomi.data.updater
|
package eu.kanade.tachiyomi.data.updater.github
|
||||||
|
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
|
import eu.kanade.tachiyomi.data.updater.Release
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Release object.
|
* Release object.
|
||||||
* Contains information about the latest release from Github.
|
* Contains information about the latest release from Github.
|
||||||
*
|
*
|
||||||
* @param version version of latest release.
|
* @param version version of latest release.
|
||||||
* @param changeLog log of latest release.
|
* @param info log of latest release.
|
||||||
* @param assets assets of latest release.
|
* @param assets assets of latest release.
|
||||||
*/
|
*/
|
||||||
class GithubRelease(@SerializedName("tag_name") val version: String,
|
class GithubRelease(@SerializedName("tag_name") val version: String,
|
||||||
@SerializedName("body") val changeLog: String,
|
@SerializedName("body") override val info: String,
|
||||||
@SerializedName("assets") private val assets: List<Assets>) {
|
@SerializedName("assets") private val assets: List<Assets>): Release {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get download link of latest release from the assets.
|
* Get download link of latest release from the assets.
|
||||||
* @return download link of latest release.
|
* @return download link of latest release.
|
||||||
*/
|
*/
|
||||||
val downloadLink: String
|
override val downloadLink: String
|
||||||
get() = assets[0].downloadLink
|
get() = assets[0].downloadLink
|
||||||
|
|
||||||
/**
|
/**
|
@ -1,4 +1,4 @@
|
|||||||
package eu.kanade.tachiyomi.data.updater
|
package eu.kanade.tachiyomi.data.updater.github
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
@ -30,4 +30,4 @@ interface GithubService {
|
|||||||
@GET("/repos/inorichi/tachiyomi/releases/latest")
|
@GET("/repos/inorichi/tachiyomi/releases/latest")
|
||||||
fun getLatestVersion(): Observable<GithubRelease>
|
fun getLatestVersion(): Observable<GithubRelease>
|
||||||
|
|
||||||
}
|
}
|
@ -1,16 +1,15 @@
|
|||||||
package eu.kanade.tachiyomi.data.updater
|
package eu.kanade.tachiyomi.data.updater.github
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.BuildConfig
|
import eu.kanade.tachiyomi.BuildConfig
|
||||||
|
import eu.kanade.tachiyomi.data.updater.UpdateChecker
|
||||||
|
import eu.kanade.tachiyomi.data.updater.UpdateResult
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
|
||||||
class GithubUpdateChecker {
|
class GithubUpdateChecker : UpdateChecker() {
|
||||||
|
|
||||||
private val service: GithubService = GithubService.create()
|
private val service: GithubService = GithubService.create()
|
||||||
|
|
||||||
/**
|
override fun checkForUpdate(): Observable<UpdateResult> {
|
||||||
* Returns observable containing release information
|
|
||||||
*/
|
|
||||||
fun checkForUpdate(): Observable<GithubUpdateResult> {
|
|
||||||
return service.getLatestVersion().map { release ->
|
return service.getLatestVersion().map { release ->
|
||||||
val newVersion = release.version.replace("[^\\d.]".toRegex(), "")
|
val newVersion = release.version.replace("[^\\d.]".toRegex(), "")
|
||||||
|
|
||||||
@ -22,4 +21,5 @@ class GithubUpdateChecker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
package eu.kanade.tachiyomi.data.updater.github
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.data.updater.UpdateResult
|
||||||
|
|
||||||
|
sealed class GithubUpdateResult : UpdateResult() {
|
||||||
|
|
||||||
|
class NewUpdate(release: GithubRelease): UpdateResult.NewUpdate<GithubRelease>(release)
|
||||||
|
class NoNewUpdate : UpdateResult.NoNewUpdate()
|
||||||
|
|
||||||
|
}
|
@ -13,7 +13,7 @@ import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
|
|||||||
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
|
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
|
||||||
import eu.kanade.tachiyomi.source.SourceManager
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
import eu.kanade.tachiyomi.util.launchNow
|
import eu.kanade.tachiyomi.util.launchNow
|
||||||
import kotlinx.coroutines.experimental.async
|
import kotlinx.coroutines.async
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import rx.android.schedulers.AndroidSchedulers
|
import rx.android.schedulers.AndroidSchedulers
|
||||||
import rx.schedulers.Schedulers
|
import rx.schedulers.Schedulers
|
||||||
|
@ -7,6 +7,7 @@ import com.github.salomonbrys.kotson.string
|
|||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.JsonArray
|
import com.google.gson.JsonArray
|
||||||
import eu.kanade.tachiyomi.extension.model.Extension
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
|
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
@ -36,17 +37,23 @@ internal class ExtensionGithubApi {
|
|||||||
|
|
||||||
val json = gson.fromJson<JsonArray>(text)
|
val json = gson.fromJson<JsonArray>(text)
|
||||||
|
|
||||||
return json.map { element ->
|
return json
|
||||||
val name = element["name"].string.substringAfter("Tachiyomi: ")
|
.filter { element ->
|
||||||
val pkgName = element["pkg"].string
|
val versionName = element["version"].string
|
||||||
val apkName = element["apk"].string
|
val libVersion = versionName.substringBeforeLast('.').toDouble()
|
||||||
val versionName = element["version"].string
|
libVersion >= ExtensionLoader.LIB_VERSION_MIN && libVersion <= ExtensionLoader.LIB_VERSION_MAX
|
||||||
val versionCode = element["code"].int
|
}
|
||||||
val lang = element["lang"].string
|
.map { element ->
|
||||||
val icon = "$repoUrl/icon/${apkName.replace(".apk", ".png")}"
|
val name = element["name"].string.substringAfter("Tachiyomi: ")
|
||||||
|
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 icon = "$repoUrl/icon/${apkName.replace(".apk", ".png")}"
|
||||||
|
|
||||||
Extension.Available(name, pkgName, versionName, versionCode, lang, apkName, icon)
|
Extension.Available(name, pkgName, versionName, versionCode, lang, apkName, icon)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getApkUrl(extension: Extension.Available): String {
|
fun getApkUrl(extension: Extension.Available): String {
|
||||||
|
@ -39,7 +39,7 @@ class ExtensionInstallActivity : Activity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun checkInstallationResult(resultCode: Int) {
|
private fun checkInstallationResult(resultCode: Int) {
|
||||||
val downloadId = intent.extras.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
|
val downloadId = intent.extras!!.getLong(ExtensionInstaller.EXTRA_DOWNLOAD_ID)
|
||||||
val success = resultCode == RESULT_OK
|
val success = resultCode == RESULT_OK
|
||||||
|
|
||||||
val extensionManager = Injekt.get<ExtensionManager>()
|
val extensionManager = Injekt.get<ExtensionManager>()
|
||||||
|
@ -7,7 +7,10 @@ import android.content.IntentFilter
|
|||||||
import eu.kanade.tachiyomi.extension.model.Extension
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
import eu.kanade.tachiyomi.extension.model.LoadResult
|
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||||
import eu.kanade.tachiyomi.util.launchNow
|
import eu.kanade.tachiyomi.util.launchNow
|
||||||
import kotlinx.coroutines.experimental.async
|
import kotlinx.coroutines.CoroutineStart
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcast receiver that listens for the system's packages installed, updated or removed, and only
|
* Broadcast receiver that listens for the system's packages installed, updated or removed, and only
|
||||||
@ -91,7 +94,7 @@ internal class ExtensionInstallReceiver(private val listener: Listener) :
|
|||||||
private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): LoadResult {
|
private suspend fun getExtensionFromIntent(context: Context, intent: Intent?): LoadResult {
|
||||||
val pkgName = getPackageNameFromIntent(intent) ?:
|
val pkgName = getPackageNameFromIntent(intent) ?:
|
||||||
return LoadResult.Error("Package name not found")
|
return LoadResult.Error("Package name not found")
|
||||||
return async { ExtensionLoader.loadExtensionFromPkgName(context, pkgName) }.await()
|
return GlobalScope.async(Dispatchers.Default, CoroutineStart.DEFAULT, { ExtensionLoader.loadExtensionFromPkgName(context, pkgName) }).await()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -13,8 +13,8 @@ import eu.kanade.tachiyomi.source.CatalogueSource
|
|||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.SourceFactory
|
import eu.kanade.tachiyomi.source.SourceFactory
|
||||||
import eu.kanade.tachiyomi.util.Hash
|
import eu.kanade.tachiyomi.util.Hash
|
||||||
import kotlinx.coroutines.experimental.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.experimental.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
@ -27,8 +27,8 @@ internal object ExtensionLoader {
|
|||||||
|
|
||||||
private const val EXTENSION_FEATURE = "tachiyomi.extension"
|
private const val EXTENSION_FEATURE = "tachiyomi.extension"
|
||||||
private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
|
private const val METADATA_SOURCE_CLASS = "tachiyomi.extension.class"
|
||||||
private const val LIB_VERSION_MIN = 1
|
const val LIB_VERSION_MIN = 1.0
|
||||||
private const val LIB_VERSION_MAX = 1
|
const val LIB_VERSION_MAX = 1.2
|
||||||
|
|
||||||
private const val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
|
private const val PACKAGE_FLAGS = PackageManager.GET_CONFIGURATIONS or PackageManager.GET_SIGNATURES
|
||||||
|
|
||||||
@ -100,10 +100,16 @@ internal object ExtensionLoader {
|
|||||||
val versionName = pkgInfo.versionName
|
val versionName = pkgInfo.versionName
|
||||||
val versionCode = pkgInfo.versionCode
|
val versionCode = pkgInfo.versionCode
|
||||||
|
|
||||||
|
if (versionName.isNullOrEmpty()) {
|
||||||
|
val exception = Exception("Missing versionName for extension $extName")
|
||||||
|
Timber.w(exception)
|
||||||
|
return LoadResult.Error(exception)
|
||||||
|
}
|
||||||
|
|
||||||
// Validate lib version
|
// Validate lib version
|
||||||
val majorLibVersion = versionName.substringBefore('.').toInt()
|
val libVersion = versionName.substringBeforeLast('.').toDouble()
|
||||||
if (majorLibVersion < LIB_VERSION_MIN || majorLibVersion > LIB_VERSION_MAX) {
|
if (libVersion < LIB_VERSION_MIN || libVersion > LIB_VERSION_MAX) {
|
||||||
val exception = Exception("Lib version is $majorLibVersion, while only versions " +
|
val exception = Exception("Lib version is $libVersion, while only versions " +
|
||||||
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed")
|
"$LIB_VERSION_MIN to $LIB_VERSION_MAX are allowed")
|
||||||
Timber.w(exception)
|
Timber.w(exception)
|
||||||
return LoadResult.Error(exception)
|
return LoadResult.Error(exception)
|
||||||
@ -121,7 +127,7 @@ internal object ExtensionLoader {
|
|||||||
|
|
||||||
val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader)
|
val classLoader = PathClassLoader(appInfo.sourceDir, null, context.classLoader)
|
||||||
|
|
||||||
val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)
|
val sources = appInfo.metaData.getString(METADATA_SOURCE_CLASS)!!
|
||||||
.split(";")
|
.split(";")
|
||||||
.map {
|
.map {
|
||||||
val sourceClass = it.trim()
|
val sourceClass = it.trim()
|
||||||
|
@ -0,0 +1,79 @@
|
|||||||
|
package eu.kanade.tachiyomi.network
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.webkit.CookieManager
|
||||||
|
import android.webkit.CookieSyncManager
|
||||||
|
import okhttp3.Cookie
|
||||||
|
import okhttp3.CookieJar
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
|
||||||
|
class AndroidCookieJar(context: Context) : CookieJar {
|
||||||
|
|
||||||
|
private val manager = CookieManager.getInstance()
|
||||||
|
|
||||||
|
private val syncManager by lazy { CookieSyncManager.createInstance(context) }
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Init sync manager when using anything below L
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
syncManager
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun saveFromResponse(url: HttpUrl, cookies: MutableList<Cookie>) {
|
||||||
|
val urlString = url.toString()
|
||||||
|
|
||||||
|
for (cookie in cookies) {
|
||||||
|
manager.setCookie(urlString, cookie.toString())
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
syncManager.sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
||||||
|
return get(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(url: HttpUrl): List<Cookie> {
|
||||||
|
val cookies = manager.getCookie(url.toString())
|
||||||
|
|
||||||
|
return if (cookies != null && !cookies.isEmpty()) {
|
||||||
|
cookies.split(";").mapNotNull { Cookie.parse(url, it) }
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(url: HttpUrl, cookieNames: List<String>? = null, maxAge: Int = -1) {
|
||||||
|
val urlString = url.toString()
|
||||||
|
val cookies = manager.getCookie(urlString) ?: return
|
||||||
|
|
||||||
|
fun List<String>.filterNames(): List<String> {
|
||||||
|
return if (cookieNames != null) {
|
||||||
|
this.filter { it in cookieNames }
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cookies.split(";")
|
||||||
|
.map { it.substringBefore("=") }
|
||||||
|
.filterNames()
|
||||||
|
.onEach { manager.setCookie(urlString, "$it=;Max-Age=$maxAge") }
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
syncManager.sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAll() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
|
||||||
|
manager.removeAllCookies {}
|
||||||
|
} else {
|
||||||
|
manager.removeAllCookie()
|
||||||
|
syncManager.sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,79 +1,140 @@
|
|||||||
package eu.kanade.tachiyomi.network
|
package eu.kanade.tachiyomi.network
|
||||||
|
|
||||||
import com.squareup.duktape.Duktape
|
import android.annotation.SuppressLint
|
||||||
import okhttp3.*
|
import android.content.Context
|
||||||
import java.io.IOException
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
class CloudflareInterceptor : Interceptor {
|
import android.os.Looper
|
||||||
|
import android.webkit.WebSettings
|
||||||
private val operationPattern = Regex("""setTimeout\(function\(\)\{\s+(var (?:\w,)+f.+?\r?\n[\s\S]+?a\.value =.+?)\r?\n""")
|
import android.webkit.WebView
|
||||||
|
import eu.kanade.tachiyomi.util.WebViewClientCompat
|
||||||
private val passPattern = Regex("""name="pass" value="(.+?)"""")
|
import okhttp3.*
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
private val challengePattern = Regex("""name="jschl_vc" value="(\w+)"""")
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare")
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@Synchronized
|
class CloudflareInterceptor(private val context: Context) : Interceptor {
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val response = chain.proceed(chain.request())
|
private val serverCheck = arrayOf("cloudflare-nginx", "cloudflare")
|
||||||
|
|
||||||
// Check if Cloudflare anti-bot is on
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
if (response.code() == 503 && response.header("Server") in serverCheck) {
|
|
||||||
return try {
|
private val networkHelper: NetworkHelper by injectLazy()
|
||||||
chain.proceed(resolveChallenge(response))
|
|
||||||
} catch (e: Exception) {
|
/**
|
||||||
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
|
* When this is called, it initializes the WebView if it wasn't already. We use this to avoid
|
||||||
// we don't crash the entire app
|
* blocking the main thread too much. If used too often we could consider moving it to the
|
||||||
throw IOException(e)
|
* Application class.
|
||||||
}
|
*/
|
||||||
}
|
private val initWebView by lazy {
|
||||||
|
if (Build.VERSION.SDK_INT >= 17) {
|
||||||
return response
|
WebSettings.getDefaultUserAgent(context)
|
||||||
}
|
} else {
|
||||||
|
null
|
||||||
private fun resolveChallenge(response: Response): Request {
|
}
|
||||||
Duktape.create().use { duktape ->
|
}
|
||||||
val originalRequest = response.request()
|
|
||||||
val url = originalRequest.url()
|
@Synchronized
|
||||||
val domain = url.host()
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val content = response.body()!!.string()
|
initWebView
|
||||||
|
|
||||||
// CloudFlare requires waiting 4 seconds before resolving the challenge
|
val originalRequest = chain.request()
|
||||||
Thread.sleep(4000)
|
val response = chain.proceed(originalRequest)
|
||||||
|
|
||||||
val operation = operationPattern.find(content)?.groups?.get(1)?.value
|
// Check if Cloudflare anti-bot is on
|
||||||
val challenge = challengePattern.find(content)?.groups?.get(1)?.value
|
if (response.code() == 503 && response.header("Server") in serverCheck) {
|
||||||
val pass = passPattern.find(content)?.groups?.get(1)?.value
|
try {
|
||||||
|
response.close()
|
||||||
if (operation == null || challenge == null || pass == null) {
|
networkHelper.cookieManager.remove(originalRequest.url(), listOf("__cfduid", "cf_clearance"), 0)
|
||||||
throw Exception("Failed resolving Cloudflare challenge")
|
val oldCookie = networkHelper.cookieManager.get(originalRequest.url())
|
||||||
}
|
.firstOrNull { it.name() == "cf_clearance" }
|
||||||
|
return if (resolveWithWebView(originalRequest, oldCookie)) {
|
||||||
val js = operation
|
chain.proceed(originalRequest)
|
||||||
.replace(Regex("""a\.value = (.+ \+ t\.length(\).toFixed\(10\))?).+"""), "$1")
|
} else {
|
||||||
.replace(Regex("""\s{3,}[a-z](?: = |\.).+"""), "")
|
throw IOException("Failed to bypass Cloudflare!")
|
||||||
.replace("t.length", "${domain.length}")
|
}
|
||||||
.replace("\n", "")
|
} catch (e: Exception) {
|
||||||
|
// Because OkHttp's enqueue only handles IOExceptions, wrap the exception so that
|
||||||
val result = duktape.evaluate(js) as String
|
// we don't crash the entire app
|
||||||
|
throw IOException(e)
|
||||||
val cloudflareUrl = HttpUrl.parse("${url.scheme()}://$domain/cdn-cgi/l/chk_jschl")!!
|
}
|
||||||
.newBuilder()
|
}
|
||||||
.addQueryParameter("jschl_vc", challenge)
|
|
||||||
.addQueryParameter("pass", pass)
|
return response
|
||||||
.addQueryParameter("jschl_answer", result)
|
}
|
||||||
.toString()
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
val cloudflareHeaders = originalRequest.headers()
|
private fun resolveWithWebView(request: Request, oldCookie: Cookie?): Boolean {
|
||||||
.newBuilder()
|
// We need to lock this thread until the WebView finds the challenge solution url, because
|
||||||
.add("Referer", url.toString())
|
// OkHttp doesn't support asynchronous interceptors.
|
||||||
.add("Accept", "text/html,application/xhtml+xml,application/xml")
|
val latch = CountDownLatch(1)
|
||||||
.add("Accept-Language", "en")
|
|
||||||
.build()
|
var webView: WebView? = null
|
||||||
|
var challengeFound = false
|
||||||
return GET(cloudflareUrl, cloudflareHeaders, cache = CacheControl.Builder().build())
|
var cloudflareBypassed = false
|
||||||
}
|
|
||||||
}
|
val origRequestUrl = request.url().toString()
|
||||||
|
val headers = request.headers().toMultimap().mapValues { it.value.getOrNull(0) ?: "" }
|
||||||
}
|
|
||||||
|
handler.post {
|
||||||
|
val view = WebView(context)
|
||||||
|
webView = view
|
||||||
|
view.settings.javaScriptEnabled = true
|
||||||
|
view.settings.userAgentString = request.header("User-Agent")
|
||||||
|
view.webViewClient = object : WebViewClientCompat() {
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView, url: String) {
|
||||||
|
fun isCloudFlareBypassed(): Boolean {
|
||||||
|
return networkHelper.cookieManager.get(HttpUrl.parse(origRequestUrl)!!)
|
||||||
|
.firstOrNull { it.name() == "cf_clearance" }
|
||||||
|
.let { it != null && it != oldCookie }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCloudFlareBypassed()) {
|
||||||
|
cloudflareBypassed = true
|
||||||
|
latch.countDown()
|
||||||
|
}
|
||||||
|
// Http error codes are only received since M
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
|
||||||
|
url == origRequestUrl && !challengeFound
|
||||||
|
) {
|
||||||
|
// The first request didn't return the challenge, abort.
|
||||||
|
latch.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedErrorCompat(
|
||||||
|
view: WebView,
|
||||||
|
errorCode: Int,
|
||||||
|
description: String?,
|
||||||
|
failingUrl: String,
|
||||||
|
isMainFrame: Boolean
|
||||||
|
) {
|
||||||
|
if (isMainFrame) {
|
||||||
|
if (errorCode == 503) {
|
||||||
|
// Found the cloudflare challenge page.
|
||||||
|
challengeFound = true
|
||||||
|
} else {
|
||||||
|
// Unlock thread, the challenge wasn't found.
|
||||||
|
latch.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
webView?.loadUrl(origRequestUrl, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a reasonable amount of time to retrieve the solution. The minimum should be
|
||||||
|
// around 4 seconds but it can take more due to slow networks or server issues.
|
||||||
|
latch.await(12, TimeUnit.SECONDS)
|
||||||
|
|
||||||
|
handler.post {
|
||||||
|
webView?.stopLoading()
|
||||||
|
webView?.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
return cloudflareBypassed
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
@ -1,128 +1,117 @@
|
|||||||
package eu.kanade.tachiyomi.network
|
package eu.kanade.tachiyomi.network
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import okhttp3.Cache
|
import okhttp3.*
|
||||||
import okhttp3.CipherSuite
|
import java.io.File
|
||||||
import okhttp3.ConnectionSpec
|
import java.io.IOException
|
||||||
import okhttp3.OkHttpClient
|
import java.net.InetAddress
|
||||||
import okhttp3.TlsVersion
|
import java.net.Socket
|
||||||
import java.io.File
|
import java.net.UnknownHostException
|
||||||
import java.io.IOException
|
import java.security.KeyManagementException
|
||||||
import java.net.InetAddress
|
import java.security.KeyStore
|
||||||
import java.net.Socket
|
import java.security.NoSuchAlgorithmException
|
||||||
import java.net.UnknownHostException
|
import javax.net.ssl.*
|
||||||
import java.security.KeyManagementException
|
|
||||||
import java.security.KeyStore
|
class NetworkHelper(context: Context) {
|
||||||
import java.security.NoSuchAlgorithmException
|
|
||||||
import javax.net.ssl.SSLContext
|
private val cacheDir = File(context.cacheDir, "network_cache")
|
||||||
import javax.net.ssl.SSLSocket
|
|
||||||
import javax.net.ssl.SSLSocketFactory
|
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
|
||||||
import javax.net.ssl.TrustManagerFactory
|
|
||||||
import javax.net.ssl.X509TrustManager
|
val cookieManager = AndroidCookieJar(context)
|
||||||
|
|
||||||
class NetworkHelper(context: Context) {
|
val client = OkHttpClient.Builder()
|
||||||
|
.cookieJar(cookieManager)
|
||||||
private val cacheDir = File(context.cacheDir, "network_cache")
|
.cache(Cache(cacheDir, cacheSize))
|
||||||
|
.enableTLS12()
|
||||||
private val cacheSize = 5L * 1024 * 1024 // 5 MiB
|
.build()
|
||||||
|
|
||||||
private val cookieManager = PersistentCookieJar(context)
|
val cloudflareClient = client.newBuilder()
|
||||||
|
.addInterceptor(CloudflareInterceptor(context))
|
||||||
val client = OkHttpClient.Builder()
|
.build()
|
||||||
.cookieJar(cookieManager)
|
|
||||||
.cache(Cache(cacheDir, cacheSize))
|
private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder {
|
||||||
.enableTLS12()
|
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) {
|
||||||
.build()
|
return this
|
||||||
|
}
|
||||||
val cloudflareClient = client.newBuilder()
|
|
||||||
.addInterceptor(CloudflareInterceptor())
|
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
.build()
|
trustManagerFactory.init(null as KeyStore?)
|
||||||
|
val trustManagers = trustManagerFactory.trustManagers
|
||||||
val cookies: PersistentCookieStore
|
if (trustManagers.size == 1 && trustManagers[0] is X509TrustManager) {
|
||||||
get() = cookieManager.store
|
class TLSSocketFactory @Throws(KeyManagementException::class, NoSuchAlgorithmException::class)
|
||||||
|
constructor() : SSLSocketFactory() {
|
||||||
private fun OkHttpClient.Builder.enableTLS12(): OkHttpClient.Builder {
|
|
||||||
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.KITKAT) {
|
private val internalSSLSocketFactory: SSLSocketFactory
|
||||||
return this
|
|
||||||
}
|
init {
|
||||||
|
val context = SSLContext.getInstance("TLS")
|
||||||
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
context.init(null, null, null)
|
||||||
trustManagerFactory.init(null as KeyStore?)
|
internalSSLSocketFactory = context.socketFactory
|
||||||
val trustManagers = trustManagerFactory.trustManagers
|
}
|
||||||
if (trustManagers.size == 1 && trustManagers[0] is X509TrustManager) {
|
|
||||||
class TLSSocketFactory @Throws(KeyManagementException::class, NoSuchAlgorithmException::class)
|
override fun getDefaultCipherSuites(): Array<String> {
|
||||||
constructor() : SSLSocketFactory() {
|
return internalSSLSocketFactory.defaultCipherSuites
|
||||||
|
}
|
||||||
private val internalSSLSocketFactory: SSLSocketFactory
|
|
||||||
|
override fun getSupportedCipherSuites(): Array<String> {
|
||||||
init {
|
return internalSSLSocketFactory.supportedCipherSuites
|
||||||
val context = SSLContext.getInstance("TLS")
|
}
|
||||||
context.init(null, null, null)
|
|
||||||
internalSSLSocketFactory = context.socketFactory
|
@Throws(IOException::class)
|
||||||
}
|
override fun createSocket(): Socket? {
|
||||||
|
return enableTLSOnSocket(internalSSLSocketFactory.createSocket())
|
||||||
override fun getDefaultCipherSuites(): Array<String> {
|
}
|
||||||
return internalSSLSocketFactory.defaultCipherSuites
|
|
||||||
}
|
@Throws(IOException::class)
|
||||||
|
override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket? {
|
||||||
override fun getSupportedCipherSuites(): Array<String> {
|
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose))
|
||||||
return internalSSLSocketFactory.supportedCipherSuites
|
}
|
||||||
}
|
|
||||||
|
@Throws(IOException::class, UnknownHostException::class)
|
||||||
@Throws(IOException::class)
|
override fun createSocket(host: String, port: Int): Socket? {
|
||||||
override fun createSocket(): Socket? {
|
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port))
|
||||||
return enableTLSOnSocket(internalSSLSocketFactory.createSocket())
|
}
|
||||||
}
|
|
||||||
|
@Throws(IOException::class, UnknownHostException::class)
|
||||||
@Throws(IOException::class)
|
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket? {
|
||||||
override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket? {
|
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort))
|
||||||
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose))
|
}
|
||||||
}
|
|
||||||
|
@Throws(IOException::class)
|
||||||
@Throws(IOException::class, UnknownHostException::class)
|
override fun createSocket(host: InetAddress, port: Int): Socket? {
|
||||||
override fun createSocket(host: String, port: Int): Socket? {
|
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port))
|
||||||
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port))
|
}
|
||||||
}
|
|
||||||
|
@Throws(IOException::class)
|
||||||
@Throws(IOException::class, UnknownHostException::class)
|
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket? {
|
||||||
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket? {
|
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort))
|
||||||
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port, localHost, localPort))
|
}
|
||||||
}
|
|
||||||
|
private fun enableTLSOnSocket(socket: Socket?): Socket? {
|
||||||
@Throws(IOException::class)
|
if (socket != null && socket is SSLSocket) {
|
||||||
override fun createSocket(host: InetAddress, port: Int): Socket? {
|
socket.enabledProtocols = socket.supportedProtocols
|
||||||
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port))
|
}
|
||||||
}
|
return socket
|
||||||
|
}
|
||||||
@Throws(IOException::class)
|
}
|
||||||
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket? {
|
|
||||||
return enableTLSOnSocket(internalSSLSocketFactory.createSocket(address, port, localAddress, localPort))
|
sslSocketFactory(TLSSocketFactory(), trustManagers[0] as X509TrustManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enableTLSOnSocket(socket: Socket?): Socket? {
|
val specCompat = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
|
||||||
if (socket != null && socket is SSLSocket) {
|
.tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0)
|
||||||
socket.enabledProtocols = socket.supportedProtocols
|
.cipherSuites(
|
||||||
}
|
*ConnectionSpec.MODERN_TLS.cipherSuites().orEmpty().toTypedArray(),
|
||||||
return socket
|
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
|
||||||
}
|
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
|
||||||
}
|
)
|
||||||
|
.build()
|
||||||
sslSocketFactory(TLSSocketFactory(), trustManagers[0] as X509TrustManager)
|
|
||||||
}
|
val specs = listOf(specCompat, ConnectionSpec.CLEARTEXT)
|
||||||
|
connectionSpecs(specs)
|
||||||
val specCompat = ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
|
|
||||||
.tlsVersions(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0)
|
return this
|
||||||
.cipherSuites(
|
}
|
||||||
*ConnectionSpec.MODERN_TLS.cipherSuites().orEmpty().toTypedArray(),
|
}
|
||||||
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
|
|
||||||
CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val specs = listOf(specCompat, ConnectionSpec.CLEARTEXT)
|
|
||||||
connectionSpecs(specs)
|
|
||||||
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,70 +1,70 @@
|
|||||||
package eu.kanade.tachiyomi.network
|
package eu.kanade.tachiyomi.network
|
||||||
|
|
||||||
import okhttp3.Call
|
import okhttp3.Call
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import rx.Producer
|
import rx.Producer
|
||||||
import rx.Subscription
|
import rx.Subscription
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
fun Call.asObservable(): Observable<Response> {
|
fun Call.asObservable(): Observable<Response> {
|
||||||
return Observable.unsafeCreate { subscriber ->
|
return Observable.unsafeCreate { subscriber ->
|
||||||
// Since Call is a one-shot type, clone it for each new subscriber.
|
// Since Call is a one-shot type, clone it for each new subscriber.
|
||||||
val call = clone()
|
val call = clone()
|
||||||
|
|
||||||
// Wrap the call in a helper which handles both unsubscription and backpressure.
|
// Wrap the call in a helper which handles both unsubscription and backpressure.
|
||||||
val requestArbiter = object : AtomicBoolean(), Producer, Subscription {
|
val requestArbiter = object : AtomicBoolean(), Producer, Subscription {
|
||||||
override fun request(n: Long) {
|
override fun request(n: Long) {
|
||||||
if (n == 0L || !compareAndSet(false, true)) return
|
if (n == 0L || !compareAndSet(false, true)) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val response = call.execute()
|
val response = call.execute()
|
||||||
if (!subscriber.isUnsubscribed) {
|
if (!subscriber.isUnsubscribed) {
|
||||||
subscriber.onNext(response)
|
subscriber.onNext(response)
|
||||||
subscriber.onCompleted()
|
subscriber.onCompleted()
|
||||||
}
|
}
|
||||||
} catch (error: Exception) {
|
} catch (error: Exception) {
|
||||||
if (!subscriber.isUnsubscribed) {
|
if (!subscriber.isUnsubscribed) {
|
||||||
subscriber.onError(error)
|
subscriber.onError(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun unsubscribe() {
|
override fun unsubscribe() {
|
||||||
call.cancel()
|
call.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isUnsubscribed(): Boolean {
|
override fun isUnsubscribed(): Boolean {
|
||||||
return call.isCanceled
|
return call.isCanceled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
subscriber.add(requestArbiter)
|
subscriber.add(requestArbiter)
|
||||||
subscriber.setProducer(requestArbiter)
|
subscriber.setProducer(requestArbiter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Call.asObservableSuccess(): Observable<Response> {
|
fun Call.asObservableSuccess(): Observable<Response> {
|
||||||
return asObservable().doOnNext { response ->
|
return asObservable().doOnNext { response ->
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
response.close()
|
response.close()
|
||||||
throw Exception("HTTP error ${response.code()}")
|
throw Exception("HTTP error ${response.code()}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call {
|
fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListener): Call {
|
||||||
val progressClient = newBuilder()
|
val progressClient = newBuilder()
|
||||||
.cache(null)
|
.cache(null)
|
||||||
.addNetworkInterceptor { chain ->
|
.addNetworkInterceptor { chain ->
|
||||||
val originalResponse = chain.proceed(chain.request())
|
val originalResponse = chain.proceed(chain.request())
|
||||||
originalResponse.newBuilder()
|
originalResponse.newBuilder()
|
||||||
.body(ProgressResponseBody(originalResponse.body()!!, listener))
|
.body(ProgressResponseBody(originalResponse.body()!!, listener))
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
return progressClient.newCall(request)
|
return progressClient.newCall(request)
|
||||||
}
|
}
|
@ -1,19 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.network
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import okhttp3.Cookie
|
|
||||||
import okhttp3.CookieJar
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
|
|
||||||
class PersistentCookieJar(context: Context) : CookieJar {
|
|
||||||
|
|
||||||
val store = PersistentCookieStore(context)
|
|
||||||
|
|
||||||
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
|
|
||||||
store.addAll(url, cookies)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun loadForRequest(url: HttpUrl): List<Cookie> {
|
|
||||||
return store.get(url)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,78 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.network
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import okhttp3.Cookie
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import java.net.URI
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
|
|
||||||
class PersistentCookieStore(context: Context) {
|
|
||||||
|
|
||||||
private val cookieMap = ConcurrentHashMap<String, List<Cookie>>()
|
|
||||||
private val prefs = context.getSharedPreferences("cookie_store", Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
init {
|
|
||||||
for ((key, value) in prefs.all) {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val cookies = value as? Set<String>
|
|
||||||
if (cookies != null) {
|
|
||||||
try {
|
|
||||||
val url = HttpUrl.parse("http://$key") ?: continue
|
|
||||||
val nonExpiredCookies = cookies.mapNotNull { Cookie.parse(url, it) }
|
|
||||||
.filter { !it.hasExpired() }
|
|
||||||
cookieMap.put(key, nonExpiredCookies)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun addAll(url: HttpUrl, cookies: List<Cookie>) {
|
|
||||||
val key = url.uri().host
|
|
||||||
|
|
||||||
// Append or replace the cookies for this domain.
|
|
||||||
val cookiesForDomain = cookieMap[key].orEmpty().toMutableList()
|
|
||||||
for (cookie in cookies) {
|
|
||||||
// Find a cookie with the same name. Replace it if found, otherwise add a new one.
|
|
||||||
val pos = cookiesForDomain.indexOfFirst { it.name() == cookie.name() }
|
|
||||||
if (pos == -1) {
|
|
||||||
cookiesForDomain.add(cookie)
|
|
||||||
} else {
|
|
||||||
cookiesForDomain[pos] = cookie
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cookieMap.put(key, cookiesForDomain)
|
|
||||||
|
|
||||||
// Get cookies to be stored in disk
|
|
||||||
val newValues = cookiesForDomain.asSequence()
|
|
||||||
.filter { it.persistent() && !it.hasExpired() }
|
|
||||||
.map(Cookie::toString)
|
|
||||||
.toSet()
|
|
||||||
|
|
||||||
prefs.edit().putStringSet(key, newValues).apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
fun removeAll() {
|
|
||||||
prefs.edit().clear().apply()
|
|
||||||
cookieMap.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun remove(uri: URI) {
|
|
||||||
prefs.edit().remove(uri.host).apply()
|
|
||||||
cookieMap.remove(uri.host)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun get(url: HttpUrl) = get(url.uri().host)
|
|
||||||
|
|
||||||
fun get(uri: URI) = get(uri.host)
|
|
||||||
|
|
||||||
private fun get(url: String): List<Cookie> {
|
|
||||||
return cookieMap[url].orEmpty().filter { !it.hasExpired() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Cookie.hasExpired() = System.currentTimeMillis() >= expiresAt()
|
|
||||||
|
|
||||||
}
|
|
@ -1,5 +1,5 @@
|
|||||||
package eu.kanade.tachiyomi.network
|
package eu.kanade.tachiyomi.network
|
||||||
|
|
||||||
interface ProgressListener {
|
interface ProgressListener {
|
||||||
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
|
fun update(bytesRead: Long, contentLength: Long, done: Boolean)
|
||||||
}
|
}
|
@ -1,40 +1,40 @@
|
|||||||
package eu.kanade.tachiyomi.network
|
package eu.kanade.tachiyomi.network
|
||||||
|
|
||||||
import okhttp3.MediaType
|
import okhttp3.MediaType
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import okio.*
|
import okio.*
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() {
|
class ProgressResponseBody(private val responseBody: ResponseBody, private val progressListener: ProgressListener) : ResponseBody() {
|
||||||
|
|
||||||
private val bufferedSource: BufferedSource by lazy {
|
private val bufferedSource: BufferedSource by lazy {
|
||||||
Okio.buffer(source(responseBody.source()))
|
Okio.buffer(source(responseBody.source()))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun contentType(): MediaType {
|
override fun contentType(): MediaType {
|
||||||
return responseBody.contentType()!!
|
return responseBody.contentType()!!
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun contentLength(): Long {
|
override fun contentLength(): Long {
|
||||||
return responseBody.contentLength()
|
return responseBody.contentLength()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun source(): BufferedSource {
|
override fun source(): BufferedSource {
|
||||||
return bufferedSource
|
return bufferedSource
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun source(source: Source): Source {
|
private fun source(source: Source): Source {
|
||||||
return object : ForwardingSource(source) {
|
return object : ForwardingSource(source) {
|
||||||
internal var totalBytesRead = 0L
|
internal var totalBytesRead = 0L
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
override fun read(sink: Buffer, byteCount: Long): Long {
|
override fun read(sink: Buffer, byteCount: Long): Long {
|
||||||
val bytesRead = super.read(sink, byteCount)
|
val bytesRead = super.read(sink, byteCount)
|
||||||
// read() returns the number of bytes read, or -1 if this source is exhausted.
|
// read() returns the number of bytes read, or -1 if this source is exhausted.
|
||||||
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
|
totalBytesRead += if (bytesRead != -1L) bytesRead else 0
|
||||||
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
|
progressListener.update(totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
|
||||||
return bytesRead
|
return bytesRead
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,32 +1,32 @@
|
|||||||
package eu.kanade.tachiyomi.network
|
package eu.kanade.tachiyomi.network
|
||||||
|
|
||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
import java.util.concurrent.TimeUnit.MINUTES
|
import java.util.concurrent.TimeUnit.MINUTES
|
||||||
|
|
||||||
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build()
|
private val DEFAULT_CACHE_CONTROL = CacheControl.Builder().maxAge(10, MINUTES).build()
|
||||||
private val DEFAULT_HEADERS = Headers.Builder().build()
|
private val DEFAULT_HEADERS = Headers.Builder().build()
|
||||||
private val DEFAULT_BODY: RequestBody = FormBody.Builder().build()
|
private val DEFAULT_BODY: RequestBody = FormBody.Builder().build()
|
||||||
|
|
||||||
fun GET(url: String,
|
fun GET(url: String,
|
||||||
headers: Headers = DEFAULT_HEADERS,
|
headers: Headers = DEFAULT_HEADERS,
|
||||||
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
|
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
|
||||||
|
|
||||||
return Request.Builder()
|
return Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.headers(headers)
|
.headers(headers)
|
||||||
.cacheControl(cache)
|
.cacheControl(cache)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun POST(url: String,
|
fun POST(url: String,
|
||||||
headers: Headers = DEFAULT_HEADERS,
|
headers: Headers = DEFAULT_HEADERS,
|
||||||
body: RequestBody = DEFAULT_BODY,
|
body: RequestBody = DEFAULT_BODY,
|
||||||
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
|
cache: CacheControl = DEFAULT_CACHE_CONTROL): Request {
|
||||||
|
|
||||||
return Request.Builder()
|
return Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.post(body)
|
.post(body)
|
||||||
.headers(headers)
|
.headers(headers)
|
||||||
.cacheControl(cache)
|
.cacheControl(cache)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
@ -1,46 +1,46 @@
|
|||||||
package eu.kanade.tachiyomi.source
|
package eu.kanade.tachiyomi.source
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
|
||||||
interface CatalogueSource : Source {
|
interface CatalogueSource : Source {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An ISO 639-1 compliant language code (two letters in lower case).
|
* An ISO 639-1 compliant language code (two letters in lower case).
|
||||||
*/
|
*/
|
||||||
val lang: String
|
val lang: String
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the source has support for latest updates.
|
* Whether the source has support for latest updates.
|
||||||
*/
|
*/
|
||||||
val supportsLatest: Boolean
|
val supportsLatest: Boolean
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable containing a page with a list of manga.
|
* Returns an observable containing a page with a list of manga.
|
||||||
*
|
*
|
||||||
* @param page the page number to retrieve.
|
* @param page the page number to retrieve.
|
||||||
*/
|
*/
|
||||||
fun fetchPopularManga(page: Int): Observable<MangasPage>
|
fun fetchPopularManga(page: Int): Observable<MangasPage>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable containing a page with a list of manga.
|
* Returns an observable containing a page with a list of manga.
|
||||||
*
|
*
|
||||||
* @param page the page number to retrieve.
|
* @param page the page number to retrieve.
|
||||||
* @param query the search query.
|
* @param query the search query.
|
||||||
* @param filters the list of filters to apply.
|
* @param filters the list of filters to apply.
|
||||||
*/
|
*/
|
||||||
fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage>
|
fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable containing a page with a list of latest manga updates.
|
* Returns an observable containing a page with a list of latest manga updates.
|
||||||
*
|
*
|
||||||
* @param page the page number to retrieve.
|
* @param page the page number to retrieve.
|
||||||
*/
|
*/
|
||||||
fun fetchLatestUpdates(page: Int): Observable<MangasPage>
|
fun fetchLatestUpdates(page: Int): Observable<MangasPage>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the list of filters for the source.
|
* Returns the list of filters for the source.
|
||||||
*/
|
*/
|
||||||
fun getFilterList(): FilterList
|
fun getFilterList(): FilterList
|
||||||
}
|
}
|
@ -2,20 +2,24 @@ package eu.kanade.tachiyomi.source
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.source.model.*
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import eu.kanade.tachiyomi.util.ChapterRecognition
|
import eu.kanade.tachiyomi.util.ChapterRecognition
|
||||||
|
import eu.kanade.tachiyomi.util.ComparatorUtil.CaseInsensitiveNaturalComparator
|
||||||
import eu.kanade.tachiyomi.util.DiskUtil
|
import eu.kanade.tachiyomi.util.DiskUtil
|
||||||
import eu.kanade.tachiyomi.util.EpubFile
|
import eu.kanade.tachiyomi.util.EpubFile
|
||||||
import eu.kanade.tachiyomi.util.ImageUtil
|
import eu.kanade.tachiyomi.util.ImageUtil
|
||||||
import junrar.Archive
|
import junrar.Archive
|
||||||
import junrar.rarfile.FileHeader
|
import junrar.rarfile.FileHeader
|
||||||
import net.greypanther.natsort.CaseInsensitiveSimpleNaturalComparator
|
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.util.Comparator
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.zip.ZipEntry
|
import java.util.zip.ZipEntry
|
||||||
@ -125,7 +129,6 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
|||||||
override fun fetchMangaDetails(manga: SManga) = Observable.just(manga)
|
override fun fetchMangaDetails(manga: SManga) = Observable.just(manga)
|
||||||
|
|
||||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
|
||||||
val chapters = getBaseDirectories(context)
|
val chapters = getBaseDirectories(context)
|
||||||
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
|
.mapNotNull { File(it, manga.url).listFiles()?.toList() }
|
||||||
.flatten()
|
.flatten()
|
||||||
@ -146,7 +149,7 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
|||||||
}
|
}
|
||||||
.sortedWith(Comparator { c1, c2 ->
|
.sortedWith(Comparator { c1, c2 ->
|
||||||
val c = c2.chapter_number.compareTo(c1.chapter_number)
|
val c = c2.chapter_number.compareTo(c1.chapter_number)
|
||||||
if (c == 0) comparator.compare(c2.name, c1.name) else c
|
if (c == 0) CaseInsensitiveNaturalComparator.compare(c2.name, c1.name) else c
|
||||||
})
|
})
|
||||||
|
|
||||||
return Observable.just(chapters)
|
return Observable.just(chapters)
|
||||||
@ -189,20 +192,19 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
|||||||
|
|
||||||
private fun updateCover(chapter: SChapter, manga: SManga): File? {
|
private fun updateCover(chapter: SChapter, manga: SManga): File? {
|
||||||
val format = getFormat(chapter)
|
val format = getFormat(chapter)
|
||||||
val comparator = CaseInsensitiveSimpleNaturalComparator.getInstance<String>()
|
|
||||||
return when (format) {
|
return when (format) {
|
||||||
is Format.Directory -> {
|
is Format.Directory -> {
|
||||||
val entry = format.file.listFiles()
|
val entry = format.file.listFiles()
|
||||||
.sortedWith(Comparator<File> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
.sortedWith(Comparator<File> { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.name, f2.name) })
|
||||||
.find { !it.isDirectory && ImageUtil.isImage(it.name, { FileInputStream(it) }) }
|
.find { !it.isDirectory && ImageUtil.isImage(it.name) { FileInputStream(it) } }
|
||||||
|
|
||||||
entry?.let { updateCover(context, manga, it.inputStream())}
|
entry?.let { updateCover(context, manga, it.inputStream())}
|
||||||
}
|
}
|
||||||
is Format.Zip -> {
|
is Format.Zip -> {
|
||||||
ZipFile(format.file).use { zip ->
|
ZipFile(format.file).use { zip ->
|
||||||
val entry = zip.entries().toList()
|
val entry = zip.entries().toList()
|
||||||
.sortedWith(Comparator<ZipEntry> { f1, f2 -> comparator.compare(f1.name, f2.name) })
|
.sortedWith(Comparator<ZipEntry> { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.name, f2.name) })
|
||||||
.find { !it.isDirectory && ImageUtil.isImage(it.name, { zip.getInputStream(it) }) }
|
.find { !it.isDirectory && ImageUtil.isImage(it.name) { zip.getInputStream(it) } }
|
||||||
|
|
||||||
entry?.let { updateCover(context, manga, zip.getInputStream(it) )}
|
entry?.let { updateCover(context, manga, zip.getInputStream(it) )}
|
||||||
}
|
}
|
||||||
@ -210,8 +212,8 @@ class LocalSource(private val context: Context) : CatalogueSource {
|
|||||||
is Format.Rar -> {
|
is Format.Rar -> {
|
||||||
Archive(format.file).use { archive ->
|
Archive(format.file).use { archive ->
|
||||||
val entry = archive.fileHeaders
|
val entry = archive.fileHeaders
|
||||||
.sortedWith(Comparator<FileHeader> { f1, f2 -> comparator.compare(f1.fileNameString, f2.fileNameString) })
|
.sortedWith(Comparator<FileHeader> { f1, f2 -> CaseInsensitiveNaturalComparator.compare(f1.fileNameString, f2.fileNameString) })
|
||||||
.find { !it.isDirectory && ImageUtil.isImage(it.fileNameString, { archive.getInputStream(it) }) }
|
.find { !it.isDirectory && ImageUtil.isImage(it.fileNameString) { archive.getInputStream(it) } }
|
||||||
|
|
||||||
entry?.let { updateCover(context, manga, archive.getInputStream(it) )}
|
entry?.let { updateCover(context, manga, archive.getInputStream(it) )}
|
||||||
}
|
}
|
||||||
|
@ -1,44 +1,44 @@
|
|||||||
package eu.kanade.tachiyomi.source
|
package eu.kanade.tachiyomi.source
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A basic interface for creating a source. It could be an online source, a local source, etc...
|
* A basic interface for creating a source. It could be an online source, a local source, etc...
|
||||||
*/
|
*/
|
||||||
interface Source {
|
interface Source {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Id for the source. Must be unique.
|
* Id for the source. Must be unique.
|
||||||
*/
|
*/
|
||||||
val id: Long
|
val id: Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Name of the source.
|
* Name of the source.
|
||||||
*/
|
*/
|
||||||
val name: String
|
val name: String
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable with the updated details for a manga.
|
* Returns an observable with the updated details for a manga.
|
||||||
*
|
*
|
||||||
* @param manga the manga to update.
|
* @param manga the manga to update.
|
||||||
*/
|
*/
|
||||||
fun fetchMangaDetails(manga: SManga): Observable<SManga>
|
fun fetchMangaDetails(manga: SManga): Observable<SManga>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable with all the available chapters for a manga.
|
* Returns an observable with all the available chapters for a manga.
|
||||||
*
|
*
|
||||||
* @param manga the manga to update.
|
* @param manga the manga to update.
|
||||||
*/
|
*/
|
||||||
fun fetchChapterList(manga: SManga): Observable<List<SChapter>>
|
fun fetchChapterList(manga: SManga): Observable<List<SChapter>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable with the list of pages a chapter has.
|
* Returns an observable with the list of pages a chapter has.
|
||||||
*
|
*
|
||||||
* @param chapter the chapter.
|
* @param chapter the chapter.
|
||||||
*/
|
*/
|
||||||
fun fetchPageList(chapter: SChapter): Observable<List<Page>>
|
fun fetchPageList(chapter: SChapter): Observable<List<Page>>
|
||||||
|
|
||||||
}
|
}
|
@ -1,89 +1,74 @@
|
|||||||
package eu.kanade.tachiyomi.source
|
package eu.kanade.tachiyomi.source
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import eu.kanade.tachiyomi.R
|
import eu.kanade.tachiyomi.R
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.source.online.english.*
|
import rx.Observable
|
||||||
import eu.kanade.tachiyomi.source.online.german.WieManga
|
|
||||||
import eu.kanade.tachiyomi.source.online.russian.Mangachan
|
open class SourceManager(private val context: Context) {
|
||||||
import eu.kanade.tachiyomi.source.online.russian.Mintmanga
|
|
||||||
import eu.kanade.tachiyomi.source.online.russian.Readmanga
|
private val sourcesMap = mutableMapOf<Long, Source>()
|
||||||
import rx.Observable
|
|
||||||
|
private val stubSourcesMap = mutableMapOf<Long, StubSource>()
|
||||||
open class SourceManager(private val context: Context) {
|
|
||||||
|
init {
|
||||||
private val sourcesMap = mutableMapOf<Long, Source>()
|
createInternalSources().forEach { registerSource(it) }
|
||||||
|
}
|
||||||
private val stubSourcesMap = mutableMapOf<Long, StubSource>()
|
|
||||||
|
open fun get(sourceKey: Long): Source? {
|
||||||
init {
|
return sourcesMap[sourceKey]
|
||||||
createInternalSources().forEach { registerSource(it) }
|
}
|
||||||
}
|
|
||||||
|
fun getOrStub(sourceKey: Long): Source {
|
||||||
open fun get(sourceKey: Long): Source? {
|
return sourcesMap[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) {
|
||||||
return sourcesMap[sourceKey]
|
StubSource(sourceKey)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
fun getOrStub(sourceKey: Long): Source {
|
|
||||||
return sourcesMap[sourceKey] ?: stubSourcesMap.getOrPut(sourceKey) {
|
fun getOnlineSources() = sourcesMap.values.filterIsInstance<HttpSource>()
|
||||||
StubSource(sourceKey)
|
|
||||||
}
|
fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>()
|
||||||
}
|
|
||||||
|
internal fun registerSource(source: Source, overwrite: Boolean = false) {
|
||||||
fun getOnlineSources() = sourcesMap.values.filterIsInstance<HttpSource>()
|
if (overwrite || !sourcesMap.containsKey(source.id)) {
|
||||||
|
sourcesMap[source.id] = source
|
||||||
fun getCatalogueSources() = sourcesMap.values.filterIsInstance<CatalogueSource>()
|
}
|
||||||
|
}
|
||||||
internal fun registerSource(source: Source, overwrite: Boolean = false) {
|
|
||||||
if (overwrite || !sourcesMap.containsKey(source.id)) {
|
internal fun unregisterSource(source: Source) {
|
||||||
sourcesMap[source.id] = source
|
sourcesMap.remove(source.id)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
private fun createInternalSources(): List<Source> = listOf(
|
||||||
internal fun unregisterSource(source: Source) {
|
LocalSource(context)
|
||||||
sourcesMap.remove(source.id)
|
)
|
||||||
}
|
|
||||||
|
private inner class StubSource(override val id: Long) : Source {
|
||||||
private fun createInternalSources(): List<Source> = listOf(
|
|
||||||
LocalSource(context),
|
override val name: String
|
||||||
Batoto(),
|
get() = id.toString()
|
||||||
Mangahere(),
|
|
||||||
Mangafox(),
|
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||||
Kissmanga(),
|
return Observable.error(getSourceNotInstalledException())
|
||||||
Readmanga(),
|
}
|
||||||
Mintmanga(),
|
|
||||||
Mangachan(),
|
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||||
Readmangatoday(),
|
return Observable.error(getSourceNotInstalledException())
|
||||||
Mangasee(),
|
}
|
||||||
WieManga()
|
|
||||||
)
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||||
|
return Observable.error(getSourceNotInstalledException())
|
||||||
private inner class StubSource(override val id: Long) : Source {
|
}
|
||||||
|
|
||||||
override val name: String
|
override fun toString(): String {
|
||||||
get() = id.toString()
|
return name
|
||||||
|
}
|
||||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
|
||||||
return Observable.error(getSourceNotInstalledException())
|
private fun getSourceNotInstalledException(): Exception {
|
||||||
}
|
return Exception(context.getString(R.string.source_not_installed, id.toString()))
|
||||||
|
}
|
||||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
}
|
||||||
return Observable.error(getSourceNotInstalledException())
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
|
||||||
return Observable.error(getSourceNotInstalledException())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun toString(): String {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getSourceNotInstalledException(): Exception {
|
|
||||||
return Exception(context.getString(R.string.source_not_installed, id.toString()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,40 +1,40 @@
|
|||||||
package eu.kanade.tachiyomi.source.model
|
package eu.kanade.tachiyomi.source.model
|
||||||
|
|
||||||
sealed class Filter<T>(val name: String, var state: T) {
|
sealed class Filter<T>(val name: String, var state: T) {
|
||||||
open class Header(name: String) : Filter<Any>(name, 0)
|
open class Header(name: String) : Filter<Any>(name, 0)
|
||||||
open class Separator(name: String = "") : Filter<Any>(name, 0)
|
open class Separator(name: String = "") : Filter<Any>(name, 0)
|
||||||
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state)
|
abstract class Select<V>(name: String, val values: Array<V>, state: Int = 0) : Filter<Int>(name, state)
|
||||||
abstract class Text(name: String, state: String = "") : Filter<String>(name, state)
|
abstract class Text(name: String, state: String = "") : Filter<String>(name, state)
|
||||||
abstract class CheckBox(name: String, state: Boolean = false) : Filter<Boolean>(name, state)
|
abstract class CheckBox(name: String, state: Boolean = false) : Filter<Boolean>(name, state)
|
||||||
abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter<Int>(name, state) {
|
abstract class TriState(name: String, state: Int = STATE_IGNORE) : Filter<Int>(name, state) {
|
||||||
fun isIgnored() = state == STATE_IGNORE
|
fun isIgnored() = state == STATE_IGNORE
|
||||||
fun isIncluded() = state == STATE_INCLUDE
|
fun isIncluded() = state == STATE_INCLUDE
|
||||||
fun isExcluded() = state == STATE_EXCLUDE
|
fun isExcluded() = state == STATE_EXCLUDE
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val STATE_IGNORE = 0
|
const val STATE_IGNORE = 0
|
||||||
const val STATE_INCLUDE = 1
|
const val STATE_INCLUDE = 1
|
||||||
const val STATE_EXCLUDE = 2
|
const val STATE_EXCLUDE = 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
abstract class Group<V>(name: String, state: List<V>): Filter<List<V>>(name, state)
|
abstract class Group<V>(name: String, state: List<V>): Filter<List<V>>(name, state)
|
||||||
|
|
||||||
abstract class Sort(name: String, val values: Array<String>, state: Selection? = null)
|
abstract class Sort(name: String, val values: Array<String>, state: Selection? = null)
|
||||||
: Filter<Sort.Selection?>(name, state) {
|
: Filter<Sort.Selection?>(name, state) {
|
||||||
data class Selection(val index: Int, val ascending: Boolean)
|
data class Selection(val index: Int, val ascending: Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
if (other !is Filter<*>) return false
|
if (other !is Filter<*>) return false
|
||||||
|
|
||||||
return name == other.name && state == other.state
|
return name == other.name && state == other.state
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
override fun hashCode(): Int {
|
||||||
var result = name.hashCode()
|
var result = name.hashCode()
|
||||||
result = 31 * result + (state?.hashCode() ?: 0)
|
result = 31 * result + (state?.hashCode() ?: 0)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -1,7 +1,7 @@
|
|||||||
package eu.kanade.tachiyomi.source.model
|
package eu.kanade.tachiyomi.source.model
|
||||||
|
|
||||||
data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list {
|
data class FilterList(val list: List<Filter<*>>) : List<Filter<*>> by list {
|
||||||
|
|
||||||
constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
|
constructor(vararg fs: Filter<*>) : this(if (fs.isNotEmpty()) fs.asList() else emptyList())
|
||||||
|
|
||||||
}
|
}
|
@ -1,3 +1,3 @@
|
|||||||
package eu.kanade.tachiyomi.source.model
|
package eu.kanade.tachiyomi.source.model
|
||||||
|
|
||||||
data class MangasPage(val mangas: List<SManga>, val hasNextPage: Boolean)
|
data class MangasPage(val mangas: List<SManga>, val hasNextPage: Boolean)
|
@ -1,48 +1,48 @@
|
|||||||
package eu.kanade.tachiyomi.source.model
|
package eu.kanade.tachiyomi.source.model
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import eu.kanade.tachiyomi.network.ProgressListener
|
import eu.kanade.tachiyomi.network.ProgressListener
|
||||||
import rx.subjects.Subject
|
import rx.subjects.Subject
|
||||||
|
|
||||||
open class Page(
|
open class Page(
|
||||||
val index: Int,
|
val index: Int,
|
||||||
val url: String = "",
|
val url: String = "",
|
||||||
var imageUrl: String? = null,
|
var imageUrl: String? = null,
|
||||||
@Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions
|
@Transient var uri: Uri? = null // Deprecated but can't be deleted due to extensions
|
||||||
) : ProgressListener {
|
) : ProgressListener {
|
||||||
|
|
||||||
val number: Int
|
val number: Int
|
||||||
get() = index + 1
|
get() = index + 1
|
||||||
|
|
||||||
@Transient @Volatile var status: Int = 0
|
@Transient @Volatile var status: Int = 0
|
||||||
set(value) {
|
set(value) {
|
||||||
field = value
|
field = value
|
||||||
statusSubject?.onNext(value)
|
statusSubject?.onNext(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transient @Volatile var progress: Int = 0
|
@Transient @Volatile var progress: Int = 0
|
||||||
|
|
||||||
@Transient private var statusSubject: Subject<Int, Int>? = null
|
@Transient private var statusSubject: Subject<Int, Int>? = null
|
||||||
|
|
||||||
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
|
override fun update(bytesRead: Long, contentLength: Long, done: Boolean) {
|
||||||
progress = if (contentLength > 0) {
|
progress = if (contentLength > 0) {
|
||||||
(100 * bytesRead / contentLength).toInt()
|
(100 * bytesRead / contentLength).toInt()
|
||||||
} else {
|
} else {
|
||||||
-1
|
-1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setStatusSubject(subject: Subject<Int, Int>?) {
|
fun setStatusSubject(subject: Subject<Int, Int>?) {
|
||||||
this.statusSubject = subject
|
this.statusSubject = subject
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
const val QUEUE = 0
|
const val QUEUE = 0
|
||||||
const val LOAD_PAGE = 1
|
const val LOAD_PAGE = 1
|
||||||
const val DOWNLOAD_IMAGE = 2
|
const val DOWNLOAD_IMAGE = 2
|
||||||
const val READY = 3
|
const val READY = 3
|
||||||
const val ERROR = 4
|
const val ERROR = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,31 +1,31 @@
|
|||||||
package eu.kanade.tachiyomi.source.model
|
package eu.kanade.tachiyomi.source.model
|
||||||
|
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
|
||||||
interface SChapter : Serializable {
|
interface SChapter : Serializable {
|
||||||
|
|
||||||
var url: String
|
var url: String
|
||||||
|
|
||||||
var name: String
|
var name: String
|
||||||
|
|
||||||
var date_upload: Long
|
var date_upload: Long
|
||||||
|
|
||||||
var chapter_number: Float
|
var chapter_number: Float
|
||||||
|
|
||||||
var scanlator: String?
|
var scanlator: String?
|
||||||
|
|
||||||
fun copyFrom(other: SChapter) {
|
fun copyFrom(other: SChapter) {
|
||||||
name = other.name
|
name = other.name
|
||||||
url = other.url
|
url = other.url
|
||||||
date_upload = other.date_upload
|
date_upload = other.date_upload
|
||||||
chapter_number = other.chapter_number
|
chapter_number = other.chapter_number
|
||||||
scanlator = other.scanlator
|
scanlator = other.scanlator
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun create(): SChapter {
|
fun create(): SChapter {
|
||||||
return SChapterImpl()
|
return SChapterImpl()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -1,15 +1,15 @@
|
|||||||
package eu.kanade.tachiyomi.source.model
|
package eu.kanade.tachiyomi.source.model
|
||||||
|
|
||||||
class SChapterImpl : SChapter {
|
class SChapterImpl : SChapter {
|
||||||
|
|
||||||
override lateinit var url: String
|
override lateinit var url: String
|
||||||
|
|
||||||
override lateinit var name: String
|
override lateinit var name: String
|
||||||
|
|
||||||
override var date_upload: Long = 0
|
override var date_upload: Long = 0
|
||||||
|
|
||||||
override var chapter_number: Float = -1f
|
override var chapter_number: Float = -1f
|
||||||
|
|
||||||
override var scanlator: String? = null
|
override var scanlator: String? = null
|
||||||
|
|
||||||
}
|
}
|
@ -1,58 +1,58 @@
|
|||||||
package eu.kanade.tachiyomi.source.model
|
package eu.kanade.tachiyomi.source.model
|
||||||
|
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
|
||||||
interface SManga : Serializable {
|
interface SManga : Serializable {
|
||||||
|
|
||||||
var url: String
|
var url: String
|
||||||
|
|
||||||
var title: String
|
var title: String
|
||||||
|
|
||||||
var artist: String?
|
var artist: String?
|
||||||
|
|
||||||
var author: String?
|
var author: String?
|
||||||
|
|
||||||
var description: String?
|
var description: String?
|
||||||
|
|
||||||
var genre: String?
|
var genre: String?
|
||||||
|
|
||||||
var status: Int
|
var status: Int
|
||||||
|
|
||||||
var thumbnail_url: String?
|
var thumbnail_url: String?
|
||||||
|
|
||||||
var initialized: Boolean
|
var initialized: Boolean
|
||||||
|
|
||||||
fun copyFrom(other: SManga) {
|
fun copyFrom(other: SManga) {
|
||||||
if (other.author != null)
|
if (other.author != null)
|
||||||
author = other.author
|
author = other.author
|
||||||
|
|
||||||
if (other.artist != null)
|
if (other.artist != null)
|
||||||
artist = other.artist
|
artist = other.artist
|
||||||
|
|
||||||
if (other.description != null)
|
if (other.description != null)
|
||||||
description = other.description
|
description = other.description
|
||||||
|
|
||||||
if (other.genre != null)
|
if (other.genre != null)
|
||||||
genre = other.genre
|
genre = other.genre
|
||||||
|
|
||||||
if (other.thumbnail_url != null)
|
if (other.thumbnail_url != null)
|
||||||
thumbnail_url = other.thumbnail_url
|
thumbnail_url = other.thumbnail_url
|
||||||
|
|
||||||
status = other.status
|
status = other.status
|
||||||
|
|
||||||
if (!initialized)
|
if (!initialized)
|
||||||
initialized = other.initialized
|
initialized = other.initialized
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val UNKNOWN = 0
|
const val UNKNOWN = 0
|
||||||
const val ONGOING = 1
|
const val ONGOING = 1
|
||||||
const val COMPLETED = 2
|
const val COMPLETED = 2
|
||||||
const val LICENSED = 3
|
const val LICENSED = 3
|
||||||
|
|
||||||
fun create(): SManga {
|
fun create(): SManga {
|
||||||
return SMangaImpl()
|
return SMangaImpl()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -1,23 +1,23 @@
|
|||||||
package eu.kanade.tachiyomi.source.model
|
package eu.kanade.tachiyomi.source.model
|
||||||
|
|
||||||
class SMangaImpl : SManga {
|
class SMangaImpl : SManga {
|
||||||
|
|
||||||
override lateinit var url: String
|
override lateinit var url: String
|
||||||
|
|
||||||
override lateinit var title: String
|
override lateinit var title: String
|
||||||
|
|
||||||
override var artist: String? = null
|
override var artist: String? = null
|
||||||
|
|
||||||
override var author: String? = null
|
override var author: String? = null
|
||||||
|
|
||||||
override var description: String? = null
|
override var description: String? = null
|
||||||
|
|
||||||
override var genre: String? = null
|
override var genre: String? = null
|
||||||
|
|
||||||
override var status: Int = 0
|
override var status: Int = 0
|
||||||
|
|
||||||
override var thumbnail_url: String? = null
|
override var thumbnail_url: String? = null
|
||||||
|
|
||||||
override var initialized: Boolean = false
|
override var initialized: Boolean = false
|
||||||
|
|
||||||
}
|
}
|
@ -1,367 +1,367 @@
|
|||||||
package eu.kanade.tachiyomi.source.online
|
package eu.kanade.tachiyomi.source.online
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
import eu.kanade.tachiyomi.network.newCallWithProgress
|
import eu.kanade.tachiyomi.network.newCallWithProgress
|
||||||
import eu.kanade.tachiyomi.source.CatalogueSource
|
import eu.kanade.tachiyomi.source.CatalogueSource
|
||||||
import eu.kanade.tachiyomi.source.model.*
|
import eu.kanade.tachiyomi.source.model.*
|
||||||
import okhttp3.Headers
|
import okhttp3.Headers
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.lang.Exception
|
import java.lang.Exception
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.net.URISyntaxException
|
import java.net.URISyntaxException
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A simple implementation for sources from a website.
|
* A simple implementation for sources from a website.
|
||||||
*/
|
*/
|
||||||
abstract class HttpSource : CatalogueSource {
|
abstract class HttpSource : CatalogueSource {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Network service.
|
* Network service.
|
||||||
*/
|
*/
|
||||||
protected val network: NetworkHelper by injectLazy()
|
protected val network: NetworkHelper by injectLazy()
|
||||||
|
|
||||||
// /**
|
// /**
|
||||||
// * Preferences that a source may need.
|
// * Preferences that a source may need.
|
||||||
// */
|
// */
|
||||||
// val preferences: SharedPreferences by lazy {
|
// val preferences: SharedPreferences by lazy {
|
||||||
// Injekt.get<Application>().getSharedPreferences("source_$id", Context.MODE_PRIVATE)
|
// Injekt.get<Application>().getSharedPreferences("source_$id", Context.MODE_PRIVATE)
|
||||||
// }
|
// }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base url of the website without the trailing slash, like: http://mysite.com
|
* Base url of the website without the trailing slash, like: http://mysite.com
|
||||||
*/
|
*/
|
||||||
abstract val baseUrl: String
|
abstract val baseUrl: String
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Version id used to generate the source id. If the site completely changes and urls are
|
* 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.
|
* incompatible, you may increase this value and it'll be considered as a new source.
|
||||||
*/
|
*/
|
||||||
open val versionId = 1
|
open val versionId = 1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Id of the source. By default it uses a generated id using the first 16 characters (64 bits)
|
* 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
|
* of the MD5 of the string: sourcename/language/versionId
|
||||||
* Note the generated id sets the sign bit to 0.
|
* Note the generated id sets the sign bit to 0.
|
||||||
*/
|
*/
|
||||||
override val id by lazy {
|
override val id by lazy {
|
||||||
val key = "${name.toLowerCase()}/$lang/$versionId"
|
val key = "${name.toLowerCase()}/$lang/$versionId"
|
||||||
val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
|
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
|
(0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Headers used for requests.
|
* Headers used for requests.
|
||||||
*/
|
*/
|
||||||
val headers: Headers by lazy { headersBuilder().build() }
|
val headers: Headers by lazy { headersBuilder().build() }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default network client for doing requests.
|
* Default network client for doing requests.
|
||||||
*/
|
*/
|
||||||
open val client: OkHttpClient
|
open val client: OkHttpClient
|
||||||
get() = network.client
|
get() = network.client
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Headers builder for requests. Implementations can override this method for custom headers.
|
* Headers builder for requests. Implementations can override this method for custom headers.
|
||||||
*/
|
*/
|
||||||
open protected fun headersBuilder() = Headers.Builder().apply {
|
open protected fun headersBuilder() = Headers.Builder().apply {
|
||||||
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
|
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Visible name of the source.
|
* Visible name of the source.
|
||||||
*/
|
*/
|
||||||
override fun toString() = "$name (${lang.toUpperCase()})"
|
override fun toString() = "$name (${lang.toUpperCase()})"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable containing a page with a list of manga. Normally it's not needed to
|
* Returns an observable containing a page with a list of manga. Normally it's not needed to
|
||||||
* override this method.
|
* override this method.
|
||||||
*
|
*
|
||||||
* @param page the page number to retrieve.
|
* @param page the page number to retrieve.
|
||||||
*/
|
*/
|
||||||
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||||
return client.newCall(popularMangaRequest(page))
|
return client.newCall(popularMangaRequest(page))
|
||||||
.asObservableSuccess()
|
.asObservableSuccess()
|
||||||
.map { response ->
|
.map { response ->
|
||||||
popularMangaParse(response)
|
popularMangaParse(response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the request for the popular manga given the page.
|
* Returns the request for the popular manga given the page.
|
||||||
*
|
*
|
||||||
* @param page the page number to retrieve.
|
* @param page the page number to retrieve.
|
||||||
*/
|
*/
|
||||||
abstract protected fun popularMangaRequest(page: Int): Request
|
abstract protected fun popularMangaRequest(page: Int): Request
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the response from the site and returns a [MangasPage] object.
|
* Parses the response from the site and returns a [MangasPage] object.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
abstract protected fun popularMangaParse(response: Response): MangasPage
|
abstract protected fun popularMangaParse(response: Response): MangasPage
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable containing a page with a list of manga. Normally it's not needed to
|
* Returns an observable containing a page with a list of manga. Normally it's not needed to
|
||||||
* override this method.
|
* override this method.
|
||||||
*
|
*
|
||||||
* @param page the page number to retrieve.
|
* @param page the page number to retrieve.
|
||||||
* @param query the search query.
|
* @param query the search query.
|
||||||
* @param filters the list of filters to apply.
|
* @param filters the list of filters to apply.
|
||||||
*/
|
*/
|
||||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||||
return client.newCall(searchMangaRequest(page, query, filters))
|
return client.newCall(searchMangaRequest(page, query, filters))
|
||||||
.asObservableSuccess()
|
.asObservableSuccess()
|
||||||
.map { response ->
|
.map { response ->
|
||||||
searchMangaParse(response)
|
searchMangaParse(response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the request for the search manga given the page.
|
* Returns the request for the search manga given the page.
|
||||||
*
|
*
|
||||||
* @param page the page number to retrieve.
|
* @param page the page number to retrieve.
|
||||||
* @param query the search query.
|
* @param query the search query.
|
||||||
* @param filters the list of filters to apply.
|
* @param filters the list of filters to apply.
|
||||||
*/
|
*/
|
||||||
abstract protected fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request
|
abstract protected fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the response from the site and returns a [MangasPage] object.
|
* Parses the response from the site and returns a [MangasPage] object.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
abstract protected fun searchMangaParse(response: Response): MangasPage
|
abstract protected fun searchMangaParse(response: Response): MangasPage
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable containing a page with a list of latest manga updates.
|
* Returns an observable containing a page with a list of latest manga updates.
|
||||||
*
|
*
|
||||||
* @param page the page number to retrieve.
|
* @param page the page number to retrieve.
|
||||||
*/
|
*/
|
||||||
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||||
return client.newCall(latestUpdatesRequest(page))
|
return client.newCall(latestUpdatesRequest(page))
|
||||||
.asObservableSuccess()
|
.asObservableSuccess()
|
||||||
.map { response ->
|
.map { response ->
|
||||||
latestUpdatesParse(response)
|
latestUpdatesParse(response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the request for latest manga given the page.
|
* Returns the request for latest manga given the page.
|
||||||
*
|
*
|
||||||
* @param page the page number to retrieve.
|
* @param page the page number to retrieve.
|
||||||
*/
|
*/
|
||||||
abstract protected fun latestUpdatesRequest(page: Int): Request
|
abstract protected fun latestUpdatesRequest(page: Int): Request
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the response from the site and returns a [MangasPage] object.
|
* Parses the response from the site and returns a [MangasPage] object.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
abstract protected fun latestUpdatesParse(response: Response): MangasPage
|
abstract protected fun latestUpdatesParse(response: Response): MangasPage
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable with the updated details for a manga. Normally it's not needed to
|
* Returns an observable with the updated details for a manga. Normally it's not needed to
|
||||||
* override this method.
|
* override this method.
|
||||||
*
|
*
|
||||||
* @param manga the manga to be updated.
|
* @param manga the manga to be updated.
|
||||||
*/
|
*/
|
||||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||||
return client.newCall(mangaDetailsRequest(manga))
|
return client.newCall(mangaDetailsRequest(manga))
|
||||||
.asObservableSuccess()
|
.asObservableSuccess()
|
||||||
.map { response ->
|
.map { response ->
|
||||||
mangaDetailsParse(response).apply { initialized = true }
|
mangaDetailsParse(response).apply { initialized = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the request for the details of a manga. Override only if it's needed to change the
|
* Returns the request for the details of a manga. Override only if it's needed to change the
|
||||||
* url, send different headers or request method like POST.
|
* url, send different headers or request method like POST.
|
||||||
*
|
*
|
||||||
* @param manga the manga to be updated.
|
* @param manga the manga to be updated.
|
||||||
*/
|
*/
|
||||||
open fun mangaDetailsRequest(manga: SManga): Request {
|
open fun mangaDetailsRequest(manga: SManga): Request {
|
||||||
return GET(baseUrl + manga.url, headers)
|
return GET(baseUrl + manga.url, headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the response from the site and returns the details of a manga.
|
* Parses the response from the site and returns the details of a manga.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
abstract protected fun mangaDetailsParse(response: Response): SManga
|
abstract protected fun mangaDetailsParse(response: Response): SManga
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
|
* Returns an observable with the updated chapter list for a manga. Normally it's not needed to
|
||||||
* override this method. If a manga is licensed an empty chapter list observable is returned
|
* override this method. If a manga is licensed an empty chapter list observable is returned
|
||||||
*
|
*
|
||||||
* @param manga the manga to look for chapters.
|
* @param manga the manga to look for chapters.
|
||||||
*/
|
*/
|
||||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||||
if (manga.status != SManga.LICENSED) {
|
if (manga.status != SManga.LICENSED) {
|
||||||
return client.newCall(chapterListRequest(manga))
|
return client.newCall(chapterListRequest(manga))
|
||||||
.asObservableSuccess()
|
.asObservableSuccess()
|
||||||
.map { response ->
|
.map { response ->
|
||||||
chapterListParse(response)
|
chapterListParse(response)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return Observable.error(Exception("Licensed - No chapters to show"))
|
return Observable.error(Exception("Licensed - No chapters to show"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the request for updating the chapter list. Override only if it's needed to override
|
* Returns the request for updating the chapter list. Override only if it's needed to override
|
||||||
* the url, send different headers or request method like POST.
|
* the url, send different headers or request method like POST.
|
||||||
*
|
*
|
||||||
* @param manga the manga to look for chapters.
|
* @param manga the manga to look for chapters.
|
||||||
*/
|
*/
|
||||||
open protected fun chapterListRequest(manga: SManga): Request {
|
open protected fun chapterListRequest(manga: SManga): Request {
|
||||||
return GET(baseUrl + manga.url, headers)
|
return GET(baseUrl + manga.url, headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the response from the site and returns a list of chapters.
|
* Parses the response from the site and returns a list of chapters.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
abstract protected fun chapterListParse(response: Response): List<SChapter>
|
abstract protected fun chapterListParse(response: Response): List<SChapter>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable with the page list for a chapter.
|
* Returns an observable with the page list for a chapter.
|
||||||
*
|
*
|
||||||
* @param chapter the chapter whose page list has to be fetched.
|
* @param chapter the chapter whose page list has to be fetched.
|
||||||
*/
|
*/
|
||||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||||
return client.newCall(pageListRequest(chapter))
|
return client.newCall(pageListRequest(chapter))
|
||||||
.asObservableSuccess()
|
.asObservableSuccess()
|
||||||
.map { response ->
|
.map { response ->
|
||||||
pageListParse(response)
|
pageListParse(response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the request for getting the page list. Override only if it's needed to override the
|
* Returns the request for getting the page list. Override only if it's needed to override the
|
||||||
* url, send different headers or request method like POST.
|
* url, send different headers or request method like POST.
|
||||||
*
|
*
|
||||||
* @param chapter the chapter whose page list has to be fetched.
|
* @param chapter the chapter whose page list has to be fetched.
|
||||||
*/
|
*/
|
||||||
open protected fun pageListRequest(chapter: SChapter): Request {
|
open protected fun pageListRequest(chapter: SChapter): Request {
|
||||||
return GET(baseUrl + chapter.url, headers)
|
return GET(baseUrl + chapter.url, headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the response from the site and returns a list of pages.
|
* Parses the response from the site and returns a list of pages.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
abstract protected fun pageListParse(response: Response): List<Page>
|
abstract protected fun pageListParse(response: Response): List<Page>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable with the page containing the source url of the image. If there's any
|
* 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.
|
* error, it will return null instead of throwing an exception.
|
||||||
*
|
*
|
||||||
* @param page the page whose source image has to be fetched.
|
* @param page the page whose source image has to be fetched.
|
||||||
*/
|
*/
|
||||||
open fun fetchImageUrl(page: Page): Observable<String> {
|
open fun fetchImageUrl(page: Page): Observable<String> {
|
||||||
return client.newCall(imageUrlRequest(page))
|
return client.newCall(imageUrlRequest(page))
|
||||||
.asObservableSuccess()
|
.asObservableSuccess()
|
||||||
.map { imageUrlParse(it) }
|
.map { imageUrlParse(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the request for getting the url to the source image. Override only if it's needed to
|
* 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.
|
* override the url, send different headers or request method like POST.
|
||||||
*
|
*
|
||||||
* @param page the chapter whose page list has to be fetched
|
* @param page the chapter whose page list has to be fetched
|
||||||
*/
|
*/
|
||||||
open protected fun imageUrlRequest(page: Page): Request {
|
open protected fun imageUrlRequest(page: Page): Request {
|
||||||
return GET(page.url, headers)
|
return GET(page.url, headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the response from the site and returns the absolute url to the source image.
|
* Parses the response from the site and returns the absolute url to the source image.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
abstract protected fun imageUrlParse(response: Response): String
|
abstract protected fun imageUrlParse(response: Response): String
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an observable with the response of the source image.
|
* Returns an observable with the response of the source image.
|
||||||
*
|
*
|
||||||
* @param page the page whose source image has to be downloaded.
|
* @param page the page whose source image has to be downloaded.
|
||||||
*/
|
*/
|
||||||
fun fetchImage(page: Page): Observable<Response> {
|
fun fetchImage(page: Page): Observable<Response> {
|
||||||
return client.newCallWithProgress(imageRequest(page), page)
|
return client.newCallWithProgress(imageRequest(page), page)
|
||||||
.asObservableSuccess()
|
.asObservableSuccess()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the request for getting the source image. Override only if it's needed to override
|
* 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.
|
* the url, send different headers or request method like POST.
|
||||||
*
|
*
|
||||||
* @param page the chapter whose page list has to be fetched
|
* @param page the chapter whose page list has to be fetched
|
||||||
*/
|
*/
|
||||||
open protected fun imageRequest(page: Page): Request {
|
open protected fun imageRequest(page: Page): Request {
|
||||||
return GET(page.imageUrl!!, headers)
|
return GET(page.imageUrl!!, headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assigns the url of the chapter without the scheme and domain. It saves some redundancy from
|
* Assigns the url of the chapter without the scheme and domain. It saves some redundancy from
|
||||||
* database and the urls could still work after a domain change.
|
* database and the urls could still work after a domain change.
|
||||||
*
|
*
|
||||||
* @param url the full url to the chapter.
|
* @param url the full url to the chapter.
|
||||||
*/
|
*/
|
||||||
fun SChapter.setUrlWithoutDomain(url: String) {
|
fun SChapter.setUrlWithoutDomain(url: String) {
|
||||||
this.url = getUrlWithoutDomain(url)
|
this.url = getUrlWithoutDomain(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assigns the url of the manga without the scheme and domain. It saves some redundancy from
|
* Assigns the url of the manga without the scheme and domain. It saves some redundancy from
|
||||||
* database and the urls could still work after a domain change.
|
* database and the urls could still work after a domain change.
|
||||||
*
|
*
|
||||||
* @param url the full url to the manga.
|
* @param url the full url to the manga.
|
||||||
*/
|
*/
|
||||||
fun SManga.setUrlWithoutDomain(url: String) {
|
fun SManga.setUrlWithoutDomain(url: String) {
|
||||||
this.url = getUrlWithoutDomain(url)
|
this.url = getUrlWithoutDomain(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the url of the given string without the scheme and domain.
|
* Returns the url of the given string without the scheme and domain.
|
||||||
*
|
*
|
||||||
* @param orig the full url.
|
* @param orig the full url.
|
||||||
*/
|
*/
|
||||||
private fun getUrlWithoutDomain(orig: String): String {
|
private fun getUrlWithoutDomain(orig: String): String {
|
||||||
try {
|
try {
|
||||||
val uri = URI(orig)
|
val uri = URI(orig)
|
||||||
var out = uri.path
|
var out = uri.path
|
||||||
if (uri.query != null)
|
if (uri.query != null)
|
||||||
out += "?" + uri.query
|
out += "?" + uri.query
|
||||||
if (uri.fragment != null)
|
if (uri.fragment != null)
|
||||||
out += "#" + uri.fragment
|
out += "#" + uri.fragment
|
||||||
return out
|
return out
|
||||||
} catch (e: URISyntaxException) {
|
} catch (e: URISyntaxException) {
|
||||||
return orig
|
return orig
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called before inserting a new chapter into database. Use it if you need to override chapter
|
* Called before inserting a new chapter into database. Use it if you need to override chapter
|
||||||
* fields, like the title or the chapter number. Do not change anything to [manga].
|
* fields, like the title or the chapter number. Do not change anything to [manga].
|
||||||
*
|
*
|
||||||
* @param chapter the chapter to be added.
|
* @param chapter the chapter to be added.
|
||||||
* @param manga the manga of the chapter.
|
* @param manga the manga of the chapter.
|
||||||
*/
|
*/
|
||||||
open fun prepareNewChapter(chapter: SChapter, manga: SManga) {
|
open fun prepareNewChapter(chapter: SChapter, manga: SManga) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the list of filters for the source.
|
* Returns the list of filters for the source.
|
||||||
*/
|
*/
|
||||||
override fun getFilterList() = FilterList()
|
override fun getFilterList() = FilterList()
|
||||||
}
|
}
|
||||||
|
@ -1,25 +1,25 @@
|
|||||||
package eu.kanade.tachiyomi.source.online
|
package eu.kanade.tachiyomi.source.online
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
|
||||||
fun HttpSource.getImageUrl(page: Page): Observable<Page> {
|
fun HttpSource.getImageUrl(page: Page): Observable<Page> {
|
||||||
page.status = Page.LOAD_PAGE
|
page.status = Page.LOAD_PAGE
|
||||||
return fetchImageUrl(page)
|
return fetchImageUrl(page)
|
||||||
.doOnError { page.status = Page.ERROR }
|
.doOnError { page.status = Page.ERROR }
|
||||||
.onErrorReturn { null }
|
.onErrorReturn { null }
|
||||||
.doOnNext { page.imageUrl = it }
|
.doOnNext { page.imageUrl = it }
|
||||||
.map { page }
|
.map { page }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
|
fun HttpSource.fetchAllImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
|
||||||
return Observable.from(pages)
|
return Observable.from(pages)
|
||||||
.filter { !it.imageUrl.isNullOrEmpty() }
|
.filter { !it.imageUrl.isNullOrEmpty() }
|
||||||
.mergeWith(fetchRemainingImageUrlsFromPageList(pages))
|
.mergeWith(fetchRemainingImageUrlsFromPageList(pages))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
|
fun HttpSource.fetchRemainingImageUrlsFromPageList(pages: List<Page>): Observable<Page> {
|
||||||
return Observable.from(pages)
|
return Observable.from(pages)
|
||||||
.filter { it.imageUrl.isNullOrEmpty() }
|
.filter { it.imageUrl.isNullOrEmpty() }
|
||||||
.concatMap { getImageUrl(it) }
|
.concatMap { getImageUrl(it) }
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
package eu.kanade.tachiyomi.source.online
|
package eu.kanade.tachiyomi.source.online
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import rx.Observable
|
import rx.Observable
|
||||||
|
|
||||||
interface LoginSource : Source {
|
interface LoginSource : Source {
|
||||||
|
|
||||||
fun isLogged(): Boolean
|
fun isLogged(): Boolean
|
||||||
|
|
||||||
fun login(username: String, password: String): Observable<Boolean>
|
fun login(username: String, password: String): Observable<Boolean>
|
||||||
|
|
||||||
fun isAuthenticationSuccessful(response: Response): Boolean
|
fun isAuthenticationSuccessful(response: Response): Boolean
|
||||||
|
|
||||||
}
|
}
|
@ -1,200 +1,200 @@
|
|||||||
package eu.kanade.tachiyomi.source.online
|
package eu.kanade.tachiyomi.source.online
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import eu.kanade.tachiyomi.util.asJsoup
|
import eu.kanade.tachiyomi.util.asJsoup
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A simple implementation for sources from a website using Jsoup, an HTML parser.
|
* A simple implementation for sources from a website using Jsoup, an HTML parser.
|
||||||
*/
|
*/
|
||||||
abstract class ParsedHttpSource : HttpSource() {
|
abstract class ParsedHttpSource : HttpSource() {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the response from the site and returns a [MangasPage] object.
|
* Parses the response from the site and returns a [MangasPage] object.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
override fun popularMangaParse(response: Response): MangasPage {
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
val document = response.asJsoup()
|
val document = response.asJsoup()
|
||||||
|
|
||||||
val mangas = document.select(popularMangaSelector()).map { element ->
|
val mangas = document.select(popularMangaSelector()).map { element ->
|
||||||
popularMangaFromElement(element)
|
popularMangaFromElement(element)
|
||||||
}
|
}
|
||||||
|
|
||||||
val hasNextPage = popularMangaNextPageSelector()?.let { selector ->
|
val hasNextPage = popularMangaNextPageSelector()?.let { selector ->
|
||||||
document.select(selector).first()
|
document.select(selector).first()
|
||||||
} != null
|
} != null
|
||||||
|
|
||||||
return MangasPage(mangas, hasNextPage)
|
return MangasPage(mangas, hasNextPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
|
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
|
||||||
*/
|
*/
|
||||||
abstract protected fun popularMangaSelector(): String
|
abstract protected fun popularMangaSelector(): String
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
|
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
|
||||||
* totally fine to fill only those two values.
|
* totally fine to fill only those two values.
|
||||||
*
|
*
|
||||||
* @param element an element obtained from [popularMangaSelector].
|
* @param element an element obtained from [popularMangaSelector].
|
||||||
*/
|
*/
|
||||||
abstract protected fun popularMangaFromElement(element: Element): SManga
|
abstract protected fun popularMangaFromElement(element: Element): SManga
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
|
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
|
||||||
* there's no next page.
|
* there's no next page.
|
||||||
*/
|
*/
|
||||||
abstract protected fun popularMangaNextPageSelector(): String?
|
abstract protected fun popularMangaNextPageSelector(): String?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the response from the site and returns a [MangasPage] object.
|
* Parses the response from the site and returns a [MangasPage] object.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
override fun searchMangaParse(response: Response): MangasPage {
|
override fun searchMangaParse(response: Response): MangasPage {
|
||||||
val document = response.asJsoup()
|
val document = response.asJsoup()
|
||||||
|
|
||||||
val mangas = document.select(searchMangaSelector()).map { element ->
|
val mangas = document.select(searchMangaSelector()).map { element ->
|
||||||
searchMangaFromElement(element)
|
searchMangaFromElement(element)
|
||||||
}
|
}
|
||||||
|
|
||||||
val hasNextPage = searchMangaNextPageSelector()?.let { selector ->
|
val hasNextPage = searchMangaNextPageSelector()?.let { selector ->
|
||||||
document.select(selector).first()
|
document.select(selector).first()
|
||||||
} != null
|
} != null
|
||||||
|
|
||||||
return MangasPage(mangas, hasNextPage)
|
return MangasPage(mangas, hasNextPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
|
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
|
||||||
*/
|
*/
|
||||||
abstract protected fun searchMangaSelector(): String
|
abstract protected fun searchMangaSelector(): String
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
|
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
|
||||||
* totally fine to fill only those two values.
|
* totally fine to fill only those two values.
|
||||||
*
|
*
|
||||||
* @param element an element obtained from [searchMangaSelector].
|
* @param element an element obtained from [searchMangaSelector].
|
||||||
*/
|
*/
|
||||||
abstract protected fun searchMangaFromElement(element: Element): SManga
|
abstract protected fun searchMangaFromElement(element: Element): SManga
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
|
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
|
||||||
* there's no next page.
|
* there's no next page.
|
||||||
*/
|
*/
|
||||||
abstract protected fun searchMangaNextPageSelector(): String?
|
abstract protected fun searchMangaNextPageSelector(): String?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the response from the site and returns a [MangasPage] object.
|
* Parses the response from the site and returns a [MangasPage] object.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||||
val document = response.asJsoup()
|
val document = response.asJsoup()
|
||||||
|
|
||||||
val mangas = document.select(latestUpdatesSelector()).map { element ->
|
val mangas = document.select(latestUpdatesSelector()).map { element ->
|
||||||
latestUpdatesFromElement(element)
|
latestUpdatesFromElement(element)
|
||||||
}
|
}
|
||||||
|
|
||||||
val hasNextPage = latestUpdatesNextPageSelector()?.let { selector ->
|
val hasNextPage = latestUpdatesNextPageSelector()?.let { selector ->
|
||||||
document.select(selector).first()
|
document.select(selector).first()
|
||||||
} != null
|
} != null
|
||||||
|
|
||||||
return MangasPage(mangas, hasNextPage)
|
return MangasPage(mangas, hasNextPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
|
* Returns the Jsoup selector that returns a list of [Element] corresponding to each manga.
|
||||||
*/
|
*/
|
||||||
abstract protected fun latestUpdatesSelector(): String
|
abstract protected fun latestUpdatesSelector(): String
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
|
* Returns a manga from the given [element]. Most sites only show the title and the url, it's
|
||||||
* totally fine to fill only those two values.
|
* totally fine to fill only those two values.
|
||||||
*
|
*
|
||||||
* @param element an element obtained from [latestUpdatesSelector].
|
* @param element an element obtained from [latestUpdatesSelector].
|
||||||
*/
|
*/
|
||||||
abstract protected fun latestUpdatesFromElement(element: Element): SManga
|
abstract protected fun latestUpdatesFromElement(element: Element): SManga
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
|
* Returns the Jsoup selector that returns the <a> tag linking to the next page, or null if
|
||||||
* there's no next page.
|
* there's no next page.
|
||||||
*/
|
*/
|
||||||
abstract protected fun latestUpdatesNextPageSelector(): String?
|
abstract protected fun latestUpdatesNextPageSelector(): String?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the response from the site and returns the details of a manga.
|
* Parses the response from the site and returns the details of a manga.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
override fun mangaDetailsParse(response: Response): SManga {
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
return mangaDetailsParse(response.asJsoup())
|
return mangaDetailsParse(response.asJsoup())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the details of the manga from the given [document].
|
* Returns the details of the manga from the given [document].
|
||||||
*
|
*
|
||||||
* @param document the parsed document.
|
* @param document the parsed document.
|
||||||
*/
|
*/
|
||||||
abstract protected fun mangaDetailsParse(document: Document): SManga
|
abstract protected fun mangaDetailsParse(document: Document): SManga
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the response from the site and returns a list of chapters.
|
* Parses the response from the site and returns a list of chapters.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
override fun chapterListParse(response: Response): List<SChapter> {
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
val document = response.asJsoup()
|
val document = response.asJsoup()
|
||||||
return document.select(chapterListSelector()).map { chapterFromElement(it) }
|
return document.select(chapterListSelector()).map { chapterFromElement(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter.
|
* Returns the Jsoup selector that returns a list of [Element] corresponding to each chapter.
|
||||||
*/
|
*/
|
||||||
abstract protected fun chapterListSelector(): String
|
abstract protected fun chapterListSelector(): String
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a chapter from the given element.
|
* Returns a chapter from the given element.
|
||||||
*
|
*
|
||||||
* @param element an element obtained from [chapterListSelector].
|
* @param element an element obtained from [chapterListSelector].
|
||||||
*/
|
*/
|
||||||
abstract protected fun chapterFromElement(element: Element): SChapter
|
abstract protected fun chapterFromElement(element: Element): SChapter
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses the response from the site and returns the page list.
|
* Parses the response from the site and returns the page list.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
override fun pageListParse(response: Response): List<Page> {
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
return pageListParse(response.asJsoup())
|
return pageListParse(response.asJsoup())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a page list from the given document.
|
* Returns a page list from the given document.
|
||||||
*
|
*
|
||||||
* @param document the parsed document.
|
* @param document the parsed document.
|
||||||
*/
|
*/
|
||||||
abstract protected fun pageListParse(document: Document): List<Page>
|
abstract protected fun pageListParse(document: Document): List<Page>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the response from the site and returns the absolute url to the source image.
|
* Parse the response from the site and returns the absolute url to the source image.
|
||||||
*
|
*
|
||||||
* @param response the response from the site.
|
* @param response the response from the site.
|
||||||
*/
|
*/
|
||||||
override fun imageUrlParse(response: Response): String {
|
override fun imageUrlParse(response: Response): String {
|
||||||
return imageUrlParse(response.asJsoup())
|
return imageUrlParse(response.asJsoup())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the absolute url to the source image from the document.
|
* Returns the absolute url to the source image from the document.
|
||||||
*
|
*
|
||||||
* @param document the parsed document.
|
* @param document the parsed document.
|
||||||
*/
|
*/
|
||||||
abstract protected fun imageUrlParse(document: Document): String
|
abstract protected fun imageUrlParse(document: Document): String
|
||||||
}
|
}
|
||||||
|
@ -1,31 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.source.online.english
|
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.source.Source
|
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
|
||||||
import rx.Observable
|
|
||||||
|
|
||||||
class Batoto : Source {
|
|
||||||
|
|
||||||
override val id: Long = 1
|
|
||||||
|
|
||||||
override val name = "Batoto"
|
|
||||||
|
|
||||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
|
||||||
return Observable.error(Exception("RIP Batoto"))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
|
||||||
return Observable.error(Exception("RIP Batoto"))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
|
||||||
return Observable.error(Exception("RIP Batoto"))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun toString(): String {
|
|
||||||
return "$name (EN)"
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,253 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.source.online.english
|
|
||||||
|
|
||||||
import com.squareup.duktape.Duktape
|
|
||||||
import eu.kanade.tachiyomi.network.GET
|
|
||||||
import eu.kanade.tachiyomi.network.POST
|
|
||||||
import eu.kanade.tachiyomi.source.model.*
|
|
||||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
|
||||||
import okhttp3.FormBody
|
|
||||||
import okhttp3.Headers
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import org.jsoup.nodes.Document
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
import timber.log.Timber
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.regex.Pattern
|
|
||||||
|
|
||||||
class Kissmanga : ParsedHttpSource() {
|
|
||||||
|
|
||||||
override val id: Long = 4
|
|
||||||
|
|
||||||
override val name = "Kissmanga"
|
|
||||||
|
|
||||||
override val baseUrl = "http://kissmanga.com"
|
|
||||||
|
|
||||||
override val lang = "en"
|
|
||||||
|
|
||||||
override val supportsLatest = true
|
|
||||||
|
|
||||||
override val client: OkHttpClient = network.cloudflareClient
|
|
||||||
|
|
||||||
override fun headersBuilder(): Headers.Builder {
|
|
||||||
return Headers.Builder()
|
|
||||||
.add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; WOW64) Gecko/20100101 Firefox/60")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaSelector() = "table.listing tr:gt(1)"
|
|
||||||
|
|
||||||
override fun latestUpdatesSelector() = "table.listing tr:gt(1)"
|
|
||||||
|
|
||||||
override fun popularMangaRequest(page: Int): Request {
|
|
||||||
return GET("$baseUrl/MangaList/MostPopular?page=$page", headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesRequest(page: Int): Request {
|
|
||||||
return GET("http://kissmanga.com/MangaList/LatestUpdate?page=$page", headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaFromElement(element: Element): SManga {
|
|
||||||
val manga = SManga.create()
|
|
||||||
element.select("td a:eq(0)").first().let {
|
|
||||||
manga.setUrlWithoutDomain(it.attr("href"))
|
|
||||||
val title = it.text()
|
|
||||||
//check if cloudfire email obfuscation is affecting title name
|
|
||||||
if (title.contains("[email protected]", true)) {
|
|
||||||
try {
|
|
||||||
var str: String = it.html()
|
|
||||||
//get the number
|
|
||||||
str = str.substringAfter("data-cfemail=\"")
|
|
||||||
str = str.substringBefore("\">[email")
|
|
||||||
val sb = StringBuilder()
|
|
||||||
//convert number to char
|
|
||||||
val r = Integer.valueOf(str.substring(0, 2), 16)!!
|
|
||||||
var i = 2
|
|
||||||
while (i < str.length) {
|
|
||||||
val c = (Integer.valueOf(str.substring(i, i + 2), 16) xor r).toChar()
|
|
||||||
sb.append(c)
|
|
||||||
i += 2
|
|
||||||
}
|
|
||||||
//replace the new word into the title
|
|
||||||
manga.title = title.replace("[email protected]", sb.toString(), true)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
//on error just default to obfuscated title
|
|
||||||
Timber.e("error parsing [email protected]", e)
|
|
||||||
manga.title = title
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
manga.title = title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesFromElement(element: Element): SManga {
|
|
||||||
return popularMangaFromElement(element)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = "li > a:contains(› Next)"
|
|
||||||
|
|
||||||
override fun latestUpdatesNextPageSelector(): String = "ul.pager > li > a:contains(Next)"
|
|
||||||
|
|
||||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
|
||||||
val form = FormBody.Builder().apply {
|
|
||||||
add("mangaName", query)
|
|
||||||
|
|
||||||
for (filter in if (filters.isEmpty()) getFilterList() else filters) {
|
|
||||||
when (filter) {
|
|
||||||
is Author -> add("authorArtist", filter.state)
|
|
||||||
is Status -> add("status", arrayOf("", "Completed", "Ongoing")[filter.state])
|
|
||||||
is GenreList -> filter.state.forEach { genre -> add("genres", genre.state.toString()) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return POST("$baseUrl/AdvanceSearch", headers, form.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun searchMangaSelector() = popularMangaSelector()
|
|
||||||
|
|
||||||
override fun searchMangaFromElement(element: Element): SManga {
|
|
||||||
return popularMangaFromElement(element)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun searchMangaNextPageSelector() = null
|
|
||||||
|
|
||||||
override fun mangaDetailsParse(document: Document): SManga {
|
|
||||||
val infoElement = document.select("div.barContent").first()
|
|
||||||
|
|
||||||
val manga = SManga.create()
|
|
||||||
manga.author = infoElement.select("p:has(span:contains(Author:)) > a").first()?.text()
|
|
||||||
manga.genre = infoElement.select("p:has(span:contains(Genres:)) > *:gt(0)").text()
|
|
||||||
manga.description = infoElement.select("p:has(span:contains(Summary:)) ~ p").text()
|
|
||||||
manga.status = infoElement.select("p:has(span:contains(Status:))").first()?.text().orEmpty().let { parseStatus(it) }
|
|
||||||
manga.thumbnail_url = document.select(".rightBox:eq(0) img").first()?.attr("src")
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parseStatus(status: String) = when {
|
|
||||||
status.contains("Ongoing") -> SManga.ONGOING
|
|
||||||
status.contains("Completed") -> SManga.COMPLETED
|
|
||||||
else -> SManga.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun chapterListSelector() = "table.listing tr:gt(1)"
|
|
||||||
|
|
||||||
override fun chapterFromElement(element: Element): SChapter {
|
|
||||||
val urlElement = element.select("a").first()
|
|
||||||
|
|
||||||
val chapter = SChapter.create()
|
|
||||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
|
||||||
chapter.name = urlElement.text()
|
|
||||||
chapter.date_upload = element.select("td:eq(1)").first()?.text()?.let {
|
|
||||||
SimpleDateFormat("MM/dd/yyyy").parse(it).time
|
|
||||||
} ?: 0
|
|
||||||
return chapter
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pageListRequest(chapter: SChapter) = POST(baseUrl + chapter.url, headers)
|
|
||||||
|
|
||||||
override fun pageListParse(response: Response): List<Page> {
|
|
||||||
val body = response.body()!!.string()
|
|
||||||
|
|
||||||
val pages = mutableListOf<Page>()
|
|
||||||
|
|
||||||
// Kissmanga now encrypts the urls, so we need to execute these two scripts in JS.
|
|
||||||
val ca = client.newCall(GET("$baseUrl/Scripts/ca.js", headers)).execute().body()!!.string()
|
|
||||||
val lo = client.newCall(GET("$baseUrl/Scripts/lo.js", headers)).execute().body()!!.string()
|
|
||||||
|
|
||||||
Duktape.create().use {
|
|
||||||
it.evaluate(ca)
|
|
||||||
it.evaluate(lo)
|
|
||||||
|
|
||||||
// There are two functions in an inline script needed to decrypt the urls. We find and
|
|
||||||
// execute them.
|
|
||||||
var p = Pattern.compile("(var.*CryptoJS.*)")
|
|
||||||
var m = p.matcher(body)
|
|
||||||
while (m.find()) {
|
|
||||||
it.evaluate(m.group(1))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finally find all the urls and decrypt them in JS.
|
|
||||||
p = Pattern.compile("""lstImages.push\((.*)\);""")
|
|
||||||
m = p.matcher(body)
|
|
||||||
|
|
||||||
var i = 0
|
|
||||||
while (m.find()) {
|
|
||||||
val url = it.evaluate(m.group(1)) as String
|
|
||||||
pages.add(Page(i++, "", url))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pageListParse(document: Document): List<Page> {
|
|
||||||
throw Exception("Not used")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun imageUrlRequest(page: Page) = GET(page.url)
|
|
||||||
|
|
||||||
override fun imageUrlParse(document: Document) = ""
|
|
||||||
|
|
||||||
private class Status : Filter.TriState("Completed")
|
|
||||||
private class Author : Filter.Text("Author")
|
|
||||||
private class Genre(name: String) : Filter.TriState(name)
|
|
||||||
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
|
|
||||||
|
|
||||||
override fun getFilterList() = FilterList(
|
|
||||||
Author(),
|
|
||||||
Status(),
|
|
||||||
GenreList(getGenreList())
|
|
||||||
)
|
|
||||||
|
|
||||||
// $("select[name=\"genres\"]").map((i,el) => `Genre("${$(el).next().text().trim()}", ${i})`).get().join(',\n')
|
|
||||||
// on http://kissmanga.com/AdvanceSearch
|
|
||||||
private fun getGenreList() = listOf(
|
|
||||||
Genre("4-Koma"),
|
|
||||||
Genre("Action"),
|
|
||||||
Genre("Adult"),
|
|
||||||
Genre("Adventure"),
|
|
||||||
Genre("Comedy"),
|
|
||||||
Genre("Comic"),
|
|
||||||
Genre("Cooking"),
|
|
||||||
Genre("Doujinshi"),
|
|
||||||
Genre("Drama"),
|
|
||||||
Genre("Ecchi"),
|
|
||||||
Genre("Fantasy"),
|
|
||||||
Genre("Gender Bender"),
|
|
||||||
Genre("Harem"),
|
|
||||||
Genre("Historical"),
|
|
||||||
Genre("Horror"),
|
|
||||||
Genre("Josei"),
|
|
||||||
Genre("Lolicon"),
|
|
||||||
Genre("Manga"),
|
|
||||||
Genre("Manhua"),
|
|
||||||
Genre("Manhwa"),
|
|
||||||
Genre("Martial Arts"),
|
|
||||||
Genre("Mature"),
|
|
||||||
Genre("Mecha"),
|
|
||||||
Genre("Medical"),
|
|
||||||
Genre("Music"),
|
|
||||||
Genre("Mystery"),
|
|
||||||
Genre("One shot"),
|
|
||||||
Genre("Psychological"),
|
|
||||||
Genre("Romance"),
|
|
||||||
Genre("School Life"),
|
|
||||||
Genre("Sci-fi"),
|
|
||||||
Genre("Seinen"),
|
|
||||||
Genre("Shotacon"),
|
|
||||||
Genre("Shoujo"),
|
|
||||||
Genre("Shoujo Ai"),
|
|
||||||
Genre("Shounen"),
|
|
||||||
Genre("Shounen Ai"),
|
|
||||||
Genre("Slice of Life"),
|
|
||||||
Genre("Smut"),
|
|
||||||
Genre("Sports"),
|
|
||||||
Genre("Supernatural"),
|
|
||||||
Genre("Tragedy"),
|
|
||||||
Genre("Webtoon"),
|
|
||||||
Genre("Yaoi"),
|
|
||||||
Genre("Yuri")
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,231 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.source.online.english
|
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.network.GET
|
|
||||||
import eu.kanade.tachiyomi.source.model.*
|
|
||||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import okhttp3.Request
|
|
||||||
import org.jsoup.nodes.Document
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
import java.text.ParseException
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class Mangafox : ParsedHttpSource() {
|
|
||||||
|
|
||||||
override val id: Long = 3
|
|
||||||
|
|
||||||
override val name = "Mangafox"
|
|
||||||
|
|
||||||
override val baseUrl = "http://mangafox.la"
|
|
||||||
|
|
||||||
override val lang = "en"
|
|
||||||
|
|
||||||
override val supportsLatest = true
|
|
||||||
|
|
||||||
override fun popularMangaSelector() = "div#mangalist > ul.list > li"
|
|
||||||
|
|
||||||
override fun popularMangaRequest(page: Int): Request {
|
|
||||||
val pageStr = if (page != 1) "$page.htm" else ""
|
|
||||||
return GET("$baseUrl/directory/$pageStr", headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesSelector() = "div#mangalist > ul.list > li"
|
|
||||||
|
|
||||||
override fun latestUpdatesRequest(page: Int): Request {
|
|
||||||
val pageStr = if (page != 1) "$page.htm" else ""
|
|
||||||
return GET("$baseUrl/directory/$pageStr?latest")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaFromElement(element: Element): SManga {
|
|
||||||
val manga = SManga.create()
|
|
||||||
element.select("a.title").first().let {
|
|
||||||
manga.setUrlWithoutDomain(it.attr("href"))
|
|
||||||
manga.title = it.text()
|
|
||||||
}
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesFromElement(element: Element): SManga {
|
|
||||||
return popularMangaFromElement(element)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = "a:has(span.next)"
|
|
||||||
|
|
||||||
override fun latestUpdatesNextPageSelector() = "a:has(span.next)"
|
|
||||||
|
|
||||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
|
||||||
val url = HttpUrl.parse("$baseUrl/search.php?name_method=cw&author_method=cw&artist_method=cw&advopts=1")!!.newBuilder().addQueryParameter("name", query)
|
|
||||||
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
|
||||||
when (filter) {
|
|
||||||
is Status -> url.addQueryParameter(filter.id, filter.state.toString())
|
|
||||||
is GenreList -> filter.state.forEach { genre -> url.addQueryParameter(genre.id, genre.state.toString()) }
|
|
||||||
is TextField -> url.addQueryParameter(filter.key, filter.state)
|
|
||||||
is Type -> url.addQueryParameter("type", if (filter.state == 0) "" else filter.state.toString())
|
|
||||||
is OrderBy -> {
|
|
||||||
url.addQueryParameter("sort", arrayOf("name", "rating", "views", "total_chapters", "last_chapter_time")[filter.state!!.index])
|
|
||||||
url.addQueryParameter("order", if (filter.state?.ascending == true) "az" else "za")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
url.addQueryParameter("page", page.toString())
|
|
||||||
return GET(url.toString(), headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun searchMangaSelector() = "div#mangalist > ul.list > li"
|
|
||||||
|
|
||||||
override fun searchMangaFromElement(element: Element): SManga {
|
|
||||||
val manga = SManga.create()
|
|
||||||
element.select("a.title").first().let {
|
|
||||||
manga.setUrlWithoutDomain(it.attr("href"))
|
|
||||||
manga.title = it.text()
|
|
||||||
}
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun searchMangaNextPageSelector() = "a:has(span.next)"
|
|
||||||
|
|
||||||
override fun mangaDetailsParse(document: Document): SManga {
|
|
||||||
val infoElement = document.select("div#title").first()
|
|
||||||
val rowElement = infoElement.select("table > tbody > tr:eq(1)").first()
|
|
||||||
val sideInfoElement = document.select("#series_info").first()
|
|
||||||
val licensedElement = document.select("div.warning").first()
|
|
||||||
|
|
||||||
val manga = SManga.create()
|
|
||||||
manga.author = rowElement.select("td:eq(1)").first()?.text()
|
|
||||||
manga.artist = rowElement.select("td:eq(2)").first()?.text()
|
|
||||||
manga.genre = rowElement.select("td:eq(3)").first()?.text()
|
|
||||||
manga.description = infoElement.select("p.summary").first()?.text()
|
|
||||||
val isLicensed = licensedElement?.text()?.contains("licensed")
|
|
||||||
if (isLicensed == true) {
|
|
||||||
manga.status = SManga.LICENSED
|
|
||||||
} else {
|
|
||||||
manga.status = sideInfoElement.select(".data").first()?.text().orEmpty().let { parseStatus(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
manga.thumbnail_url = sideInfoElement.select("div.cover > img").first()?.attr("src")
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseStatus(status: String) = when {
|
|
||||||
status.contains("Ongoing") -> SManga.ONGOING
|
|
||||||
status.contains("Completed") -> SManga.COMPLETED
|
|
||||||
else -> SManga.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun chapterListSelector() = "div#chapters li div"
|
|
||||||
|
|
||||||
override fun chapterFromElement(element: Element): SChapter {
|
|
||||||
val urlElement = element.select("a.tips").first()
|
|
||||||
|
|
||||||
val chapter = SChapter.create()
|
|
||||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
|
||||||
chapter.name = element.select("span.title.nowrap").first()?.text()?.let { urlElement.text() + " - " + it } ?: urlElement.text()
|
|
||||||
chapter.date_upload = element.select("span.date").first()?.text()?.let { parseChapterDate(it) } ?: 0
|
|
||||||
return chapter
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseChapterDate(date: String): Long {
|
|
||||||
return if ("Today" in date || " ago" in date) {
|
|
||||||
Calendar.getInstance().apply {
|
|
||||||
set(Calendar.HOUR_OF_DAY, 0)
|
|
||||||
set(Calendar.MINUTE, 0)
|
|
||||||
set(Calendar.SECOND, 0)
|
|
||||||
set(Calendar.MILLISECOND, 0)
|
|
||||||
}.timeInMillis
|
|
||||||
} else if ("Yesterday" in date) {
|
|
||||||
Calendar.getInstance().apply {
|
|
||||||
add(Calendar.DATE, -1)
|
|
||||||
set(Calendar.HOUR_OF_DAY, 0)
|
|
||||||
set(Calendar.MINUTE, 0)
|
|
||||||
set(Calendar.SECOND, 0)
|
|
||||||
set(Calendar.MILLISECOND, 0)
|
|
||||||
}.timeInMillis
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).parse(date).time
|
|
||||||
} catch (e: ParseException) {
|
|
||||||
0L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pageListParse(document: Document): List<Page> {
|
|
||||||
val url = document.baseUri().substringBeforeLast('/')
|
|
||||||
|
|
||||||
val pages = mutableListOf<Page>()
|
|
||||||
document.select("select.m").first()?.select("option:not([value=0])")?.forEach {
|
|
||||||
pages.add(Page(pages.size, "$url/${it.attr("value")}.html"))
|
|
||||||
}
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun imageUrlParse(document: Document): String {
|
|
||||||
val url = document.getElementById("image").attr("src")
|
|
||||||
return if ("compressed?token=" !in url) {
|
|
||||||
url
|
|
||||||
} else {
|
|
||||||
"http://mangafox.me/media/logo.png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class Status(val id: String = "is_completed") : Filter.TriState("Completed")
|
|
||||||
private class Genre(name: String, val id: String = "genres[$name]") : Filter.TriState(name)
|
|
||||||
private class TextField(name: String, val key: String) : Filter.Text(name)
|
|
||||||
private class Type : Filter.Select<String>("Type", arrayOf("Any", "Japanese Manga", "Korean Manhwa", "Chinese Manhua"))
|
|
||||||
private class OrderBy : Filter.Sort("Order by",
|
|
||||||
arrayOf("Series name", "Rating", "Views", "Total chapters", "Last chapter"),
|
|
||||||
Filter.Sort.Selection(2, false))
|
|
||||||
|
|
||||||
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
|
|
||||||
|
|
||||||
override fun getFilterList() = FilterList(
|
|
||||||
TextField("Author", "author"),
|
|
||||||
TextField("Artist", "artist"),
|
|
||||||
Type(),
|
|
||||||
Status(),
|
|
||||||
OrderBy(),
|
|
||||||
GenreList(getGenreList())
|
|
||||||
)
|
|
||||||
|
|
||||||
// $('select.genres').map((i,el)=>`Genre("${$(el).next().text().trim()}", "${$(el).attr('name')}")`).get().join(',\n')
|
|
||||||
// on http://mangafox.me/search.php
|
|
||||||
private fun getGenreList() = listOf(
|
|
||||||
Genre("Action"),
|
|
||||||
Genre("Adult"),
|
|
||||||
Genre("Adventure"),
|
|
||||||
Genre("Comedy"),
|
|
||||||
Genre("Doujinshi"),
|
|
||||||
Genre("Drama"),
|
|
||||||
Genre("Ecchi"),
|
|
||||||
Genre("Fantasy"),
|
|
||||||
Genre("Gender Bender"),
|
|
||||||
Genre("Harem"),
|
|
||||||
Genre("Historical"),
|
|
||||||
Genre("Horror"),
|
|
||||||
Genre("Josei"),
|
|
||||||
Genre("Martial Arts"),
|
|
||||||
Genre("Mature"),
|
|
||||||
Genre("Mecha"),
|
|
||||||
Genre("Mystery"),
|
|
||||||
Genre("One Shot"),
|
|
||||||
Genre("Psychological"),
|
|
||||||
Genre("Romance"),
|
|
||||||
Genre("School Life"),
|
|
||||||
Genre("Sci-fi"),
|
|
||||||
Genre("Seinen"),
|
|
||||||
Genre("Shoujo"),
|
|
||||||
Genre("Shoujo Ai"),
|
|
||||||
Genre("Shounen"),
|
|
||||||
Genre("Shounen Ai"),
|
|
||||||
Genre("Slice of Life"),
|
|
||||||
Genre("Smut"),
|
|
||||||
Genre("Sports"),
|
|
||||||
Genre("Supernatural"),
|
|
||||||
Genre("Tragedy"),
|
|
||||||
Genre("Webtoons"),
|
|
||||||
Genre("Yaoi"),
|
|
||||||
Genre("Yuri")
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
@ -1,259 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.source.online.english
|
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.network.GET
|
|
||||||
import eu.kanade.tachiyomi.source.model.*
|
|
||||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import okhttp3.Request
|
|
||||||
import org.jsoup.nodes.Document
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
import java.security.SecureRandom
|
|
||||||
import java.security.cert.X509Certificate
|
|
||||||
import java.text.ParseException
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
import javax.net.ssl.SSLContext
|
|
||||||
import javax.net.ssl.X509TrustManager
|
|
||||||
|
|
||||||
class Mangahere : ParsedHttpSource() {
|
|
||||||
|
|
||||||
override val id: Long = 2
|
|
||||||
|
|
||||||
override val name = "Mangahere"
|
|
||||||
|
|
||||||
override val baseUrl = "http://www.mangahere.cc"
|
|
||||||
|
|
||||||
override val lang = "en"
|
|
||||||
|
|
||||||
override val supportsLatest = true
|
|
||||||
|
|
||||||
private val trustManager = object : X509TrustManager {
|
|
||||||
override fun getAcceptedIssuers(): Array<X509Certificate> {
|
|
||||||
return emptyArray()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val sslContext = SSLContext.getInstance("SSL").apply {
|
|
||||||
init(null, arrayOf(trustManager), SecureRandom())
|
|
||||||
}
|
|
||||||
|
|
||||||
override val client = super.client.newBuilder()
|
|
||||||
.sslSocketFactory(sslContext.socketFactory, trustManager)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
override fun popularMangaSelector() = "div.directory_list > ul > li"
|
|
||||||
|
|
||||||
override fun latestUpdatesSelector() = "div.directory_list > ul > li"
|
|
||||||
|
|
||||||
override fun popularMangaRequest(page: Int): Request {
|
|
||||||
return GET("$baseUrl/directory/$page.htm?views.za", headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesRequest(page: Int): Request {
|
|
||||||
return GET("$baseUrl/directory/$page.htm?last_chapter_time.za", headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mangaFromElement(query: String, element: Element): SManga {
|
|
||||||
val manga = SManga.create()
|
|
||||||
element.select(query).first().let {
|
|
||||||
manga.setUrlWithoutDomain(it.attr("href"))
|
|
||||||
manga.title = if (it.hasAttr("title")) it.attr("title") else if (it.hasAttr("rel")) it.attr("rel") else it.text()
|
|
||||||
}
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaFromElement(element: Element): SManga {
|
|
||||||
return mangaFromElement("div.title > a", element)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesFromElement(element: Element): SManga {
|
|
||||||
return popularMangaFromElement(element)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = "div.next-page > a.next"
|
|
||||||
|
|
||||||
override fun latestUpdatesNextPageSelector() = "div.next-page > a.next"
|
|
||||||
|
|
||||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
|
||||||
val url = HttpUrl.parse("$baseUrl/search.php?name_method=cw&author_method=cw&artist_method=cw&advopts=1")!!.newBuilder().addQueryParameter("name", query)
|
|
||||||
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
|
||||||
when (filter) {
|
|
||||||
is Status -> url.addQueryParameter("is_completed", arrayOf("", "1", "0")[filter.state])
|
|
||||||
is GenreList -> filter.state.forEach { genre -> url.addQueryParameter(genre.id, genre.state.toString()) }
|
|
||||||
is TextField -> url.addQueryParameter(filter.key, filter.state)
|
|
||||||
is Type -> url.addQueryParameter("direction", arrayOf("", "rl", "lr")[filter.state])
|
|
||||||
is OrderBy -> {
|
|
||||||
url.addQueryParameter("sort", arrayOf("name", "rating", "views", "total_chapters", "last_chapter_time")[filter.state!!.index])
|
|
||||||
url.addQueryParameter("order", if (filter.state?.ascending == true) "az" else "za")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
url.addQueryParameter("page", page.toString())
|
|
||||||
return GET(url.toString(), headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun searchMangaSelector() = "div.result_search > dl:has(dt)"
|
|
||||||
|
|
||||||
override fun searchMangaFromElement(element: Element): SManga {
|
|
||||||
return mangaFromElement("a.manga_info", element)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun searchMangaNextPageSelector() = "div.next-page > a.next"
|
|
||||||
|
|
||||||
override fun mangaDetailsParse(document: Document): SManga {
|
|
||||||
val detailElement = document.select(".manga_detail_top").first()
|
|
||||||
val infoElement = detailElement.select(".detail_topText").first()
|
|
||||||
val licensedElement = document.select(".mt10.color_ff00.mb10").first()
|
|
||||||
|
|
||||||
val manga = SManga.create()
|
|
||||||
manga.author = infoElement.select("a[href*=author/]").first()?.text()
|
|
||||||
manga.artist = infoElement.select("a[href*=artist/]").first()?.text()
|
|
||||||
manga.genre = infoElement.select("li:eq(3)").first()?.text()?.substringAfter("Genre(s):")
|
|
||||||
manga.description = infoElement.select("#show").first()?.text()?.substringBeforeLast("Show less")
|
|
||||||
manga.thumbnail_url = detailElement.select("img.img").first()?.attr("src")
|
|
||||||
|
|
||||||
if (licensedElement?.text()?.contains("licensed") == true) {
|
|
||||||
manga.status = SManga.LICENSED
|
|
||||||
} else {
|
|
||||||
manga.status = infoElement.select("li:eq(6)").first()?.text().orEmpty().let { parseStatus(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseStatus(status: String) = when {
|
|
||||||
status.contains("Ongoing") -> SManga.ONGOING
|
|
||||||
status.contains("Completed") -> SManga.COMPLETED
|
|
||||||
else -> SManga.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun chapterListSelector() = ".detail_list > ul:not([class]) > li"
|
|
||||||
|
|
||||||
override fun chapterFromElement(element: Element): SChapter {
|
|
||||||
val parentEl = element.select("span.left").first()
|
|
||||||
|
|
||||||
val urlElement = parentEl.select("a").first()
|
|
||||||
|
|
||||||
var volume = parentEl.select("span.mr6")?.first()?.text()?.trim() ?: ""
|
|
||||||
if (volume.length > 0) {
|
|
||||||
volume = " - " + volume
|
|
||||||
}
|
|
||||||
|
|
||||||
var title = parentEl?.textNodes()?.last()?.text()?.trim() ?: ""
|
|
||||||
if (title.length > 0) {
|
|
||||||
title = " - " + title
|
|
||||||
}
|
|
||||||
|
|
||||||
val chapter = SChapter.create()
|
|
||||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
|
||||||
chapter.name = urlElement.text() + volume + title
|
|
||||||
chapter.date_upload = element.select("span.right").first()?.text()?.let { parseChapterDate(it) } ?: 0
|
|
||||||
return chapter
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseChapterDate(date: String): Long {
|
|
||||||
return if ("Today" in date) {
|
|
||||||
Calendar.getInstance().apply {
|
|
||||||
set(Calendar.HOUR_OF_DAY, 0)
|
|
||||||
set(Calendar.MINUTE, 0)
|
|
||||||
set(Calendar.SECOND, 0)
|
|
||||||
set(Calendar.MILLISECOND, 0)
|
|
||||||
}.timeInMillis
|
|
||||||
} else if ("Yesterday" in date) {
|
|
||||||
Calendar.getInstance().apply {
|
|
||||||
add(Calendar.DATE, -1)
|
|
||||||
set(Calendar.HOUR_OF_DAY, 0)
|
|
||||||
set(Calendar.MINUTE, 0)
|
|
||||||
set(Calendar.SECOND, 0)
|
|
||||||
set(Calendar.MILLISECOND, 0)
|
|
||||||
}.timeInMillis
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH).parse(date).time
|
|
||||||
} catch (e: ParseException) {
|
|
||||||
0L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pageListParse(document: Document): List<Page> {
|
|
||||||
val licensedError = document.select(".mangaread_error > .mt10").first()
|
|
||||||
if (licensedError != null) {
|
|
||||||
throw Exception(licensedError.text())
|
|
||||||
}
|
|
||||||
|
|
||||||
val pages = mutableListOf<Page>()
|
|
||||||
document.select("select.wid60").first()?.getElementsByTag("option")?.forEach {
|
|
||||||
if (!it.attr("value").contains("featured.html")) {
|
|
||||||
pages.add(Page(pages.size, "http:" + it.attr("value")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun imageUrlParse(document: Document) = document.getElementById("image").attr("src")
|
|
||||||
|
|
||||||
private class Status : Filter.TriState("Completed")
|
|
||||||
private class Genre(name: String, val id: String = "genres[$name]") : Filter.TriState(name)
|
|
||||||
private class TextField(name: String, val key: String) : Filter.Text(name)
|
|
||||||
private class Type : Filter.Select<String>("Type", arrayOf("Any", "Japanese Manga (read from right to left)", "Korean Manhwa (read from left to right)"))
|
|
||||||
private class OrderBy : Filter.Sort("Order by",
|
|
||||||
arrayOf("Series name", "Rating", "Views", "Total chapters", "Last chapter"),
|
|
||||||
Filter.Sort.Selection(2, false))
|
|
||||||
|
|
||||||
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
|
|
||||||
|
|
||||||
override fun getFilterList() = FilterList(
|
|
||||||
TextField("Author", "author"),
|
|
||||||
TextField("Artist", "artist"),
|
|
||||||
Type(),
|
|
||||||
Status(),
|
|
||||||
OrderBy(),
|
|
||||||
GenreList(getGenreList())
|
|
||||||
)
|
|
||||||
|
|
||||||
// [...document.querySelectorAll("select[id^='genres'")].map((el,i) => `Genre("${el.nextSibling.nextSibling.textContent.trim()}", "${el.getAttribute('name')}")`).join(',\n')
|
|
||||||
// http://www.mangahere.co/advsearch.htm
|
|
||||||
private fun getGenreList() = listOf(
|
|
||||||
Genre("Action"),
|
|
||||||
Genre("Adventure"),
|
|
||||||
Genre("Comedy"),
|
|
||||||
Genre("Doujinshi"),
|
|
||||||
Genre("Drama"),
|
|
||||||
Genre("Ecchi"),
|
|
||||||
Genre("Fantasy"),
|
|
||||||
Genre("Gender Bender"),
|
|
||||||
Genre("Harem"),
|
|
||||||
Genre("Historical"),
|
|
||||||
Genre("Horror"),
|
|
||||||
Genre("Josei"),
|
|
||||||
Genre("Martial Arts"),
|
|
||||||
Genre("Mature"),
|
|
||||||
Genre("Mecha"),
|
|
||||||
Genre("Mystery"),
|
|
||||||
Genre("One Shot"),
|
|
||||||
Genre("Psychological"),
|
|
||||||
Genre("Romance"),
|
|
||||||
Genre("School Life"),
|
|
||||||
Genre("Sci-fi"),
|
|
||||||
Genre("Seinen"),
|
|
||||||
Genre("Shoujo"),
|
|
||||||
Genre("Shoujo Ai"),
|
|
||||||
Genre("Shounen"),
|
|
||||||
Genre("Shounen Ai"),
|
|
||||||
Genre("Slice of Life"),
|
|
||||||
Genre("Sports"),
|
|
||||||
Genre("Supernatural"),
|
|
||||||
Genre("Tragedy"),
|
|
||||||
Genre("Yaoi"),
|
|
||||||
Genre("Yuri")
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
@ -1,249 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.source.online.english
|
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.network.POST
|
|
||||||
import eu.kanade.tachiyomi.source.model.*
|
|
||||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
|
||||||
import okhttp3.FormBody
|
|
||||||
import okhttp3.Headers
|
|
||||||
import okhttp3.HttpUrl
|
|
||||||
import okhttp3.Request
|
|
||||||
import org.jsoup.nodes.Document
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.regex.Pattern
|
|
||||||
|
|
||||||
class Mangasee : ParsedHttpSource() {
|
|
||||||
|
|
||||||
override val id: Long = 9
|
|
||||||
|
|
||||||
override val name = "Mangasee"
|
|
||||||
|
|
||||||
override val baseUrl = "http://mangaseeonline.us"
|
|
||||||
|
|
||||||
override val lang = "en"
|
|
||||||
|
|
||||||
override val supportsLatest = true
|
|
||||||
|
|
||||||
private val recentUpdatesPattern = Pattern.compile("(.*?)\\s(\\d+\\.?\\d*)\\s?(Completed)?")
|
|
||||||
|
|
||||||
private val indexPattern = Pattern.compile("-index-(.*?)-")
|
|
||||||
|
|
||||||
private val catalogHeaders = Headers.Builder().apply {
|
|
||||||
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
|
|
||||||
add("Host", "mangaseeonline.us")
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
override fun popularMangaSelector() = "div.requested > div.row"
|
|
||||||
|
|
||||||
override fun popularMangaRequest(page: Int): Request {
|
|
||||||
val (body, requestUrl) = convertQueryToPost(page, "$baseUrl/search/request.php?sortBy=popularity&sortOrder=descending")
|
|
||||||
return POST(requestUrl, catalogHeaders, body.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaFromElement(element: Element): SManga {
|
|
||||||
val manga = SManga.create()
|
|
||||||
element.select("a.resultLink").first().let {
|
|
||||||
manga.setUrlWithoutDomain(it.attr("href"))
|
|
||||||
manga.title = it.text()
|
|
||||||
}
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = "button.requestMore"
|
|
||||||
|
|
||||||
override fun searchMangaSelector() = "div.requested > div.row"
|
|
||||||
|
|
||||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
|
||||||
val url = HttpUrl.parse("$baseUrl/search/request.php")!!.newBuilder()
|
|
||||||
if (!query.isEmpty()) url.addQueryParameter("keyword", query)
|
|
||||||
val genres = mutableListOf<String>()
|
|
||||||
val genresNo = mutableListOf<String>()
|
|
||||||
for (filter in if (filters.isEmpty()) getFilterList() else filters) {
|
|
||||||
when (filter) {
|
|
||||||
is Sort -> {
|
|
||||||
if (filter.state?.index != 0)
|
|
||||||
url.addQueryParameter("sortBy", if (filter.state?.index == 1) "dateUpdated" else "popularity")
|
|
||||||
if (filter.state?.ascending != true)
|
|
||||||
url.addQueryParameter("sortOrder", "descending")
|
|
||||||
}
|
|
||||||
is SelectField -> if (filter.state != 0) url.addQueryParameter(filter.key, filter.values[filter.state])
|
|
||||||
is TextField -> if (!filter.state.isEmpty()) url.addQueryParameter(filter.key, filter.state)
|
|
||||||
is GenreList -> filter.state.forEach { genre ->
|
|
||||||
when (genre.state) {
|
|
||||||
Filter.TriState.STATE_INCLUDE -> genres.add(genre.name)
|
|
||||||
Filter.TriState.STATE_EXCLUDE -> genresNo.add(genre.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (genres.isNotEmpty()) url.addQueryParameter("genre", genres.joinToString(","))
|
|
||||||
if (genresNo.isNotEmpty()) url.addQueryParameter("genreNo", genresNo.joinToString(","))
|
|
||||||
|
|
||||||
val (body, requestUrl) = convertQueryToPost(page, url.toString())
|
|
||||||
return POST(requestUrl, catalogHeaders, body.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun convertQueryToPost(page: Int, url: String): Pair<FormBody.Builder, String> {
|
|
||||||
val url = HttpUrl.parse(url)!!
|
|
||||||
val body = FormBody.Builder().add("page", page.toString())
|
|
||||||
for (i in 0..url.querySize() - 1) {
|
|
||||||
body.add(url.queryParameterName(i), url.queryParameterValue(i))
|
|
||||||
}
|
|
||||||
val requestUrl = url.scheme() + "://" + url.host() + url.encodedPath()
|
|
||||||
return Pair(body, requestUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun searchMangaFromElement(element: Element): SManga {
|
|
||||||
val manga = SManga.create()
|
|
||||||
element.select("a.resultLink").first().let {
|
|
||||||
manga.setUrlWithoutDomain(it.attr("href"))
|
|
||||||
manga.title = it.text()
|
|
||||||
}
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun searchMangaNextPageSelector() = "button.requestMore"
|
|
||||||
|
|
||||||
override fun mangaDetailsParse(document: Document): SManga {
|
|
||||||
val detailElement = document.select("div.well > div.row").first()
|
|
||||||
|
|
||||||
val manga = SManga.create()
|
|
||||||
manga.author = detailElement.select("a[href^=/search/?author=]").first()?.text()
|
|
||||||
manga.genre = detailElement.select("span.details > div.row > div:has(b:contains(Genre(s))) > a").map { it.text() }.joinToString()
|
|
||||||
manga.description = detailElement.select("strong:contains(Description:) + div").first()?.text()
|
|
||||||
manga.status = detailElement.select("a[href^=/search/?status=]").first()?.text().orEmpty().let { parseStatus(it) }
|
|
||||||
manga.thumbnail_url = detailElement.select("div > img").first()?.absUrl("src")
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseStatus(status: String) = when {
|
|
||||||
status.contains("Ongoing (Scan)") -> SManga.ONGOING
|
|
||||||
status.contains("Complete (Scan)") -> SManga.COMPLETED
|
|
||||||
else -> SManga.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun chapterListSelector() = "div.chapter-list > a"
|
|
||||||
|
|
||||||
override fun chapterFromElement(element: Element): SChapter {
|
|
||||||
val urlElement = element.select("a").first()
|
|
||||||
|
|
||||||
val chapter = SChapter.create()
|
|
||||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
|
||||||
chapter.name = element.select("span.chapterLabel").first().text()?.let { it } ?: ""
|
|
||||||
chapter.date_upload = element.select("time").first()?.attr("datetime")?.let { parseChapterDate(it) } ?: 0
|
|
||||||
return chapter
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseChapterDate(dateAsString: String): Long {
|
|
||||||
return SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse(dateAsString).time
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pageListParse(document: Document): List<Page> {
|
|
||||||
val fullUrl = document.baseUri()
|
|
||||||
val url = fullUrl.substringBeforeLast('/')
|
|
||||||
|
|
||||||
val pages = mutableListOf<Page>()
|
|
||||||
|
|
||||||
val series = document.select("input.IndexName").first().attr("value")
|
|
||||||
val chapter = document.select("span.CurChapter").first().text()
|
|
||||||
var index = ""
|
|
||||||
|
|
||||||
val m = indexPattern.matcher(fullUrl)
|
|
||||||
if (m.find()) {
|
|
||||||
val indexNumber = m.group(1)
|
|
||||||
index = "-index-$indexNumber"
|
|
||||||
}
|
|
||||||
|
|
||||||
document.select("div.ContainerNav").first().select("select.PageSelect > option").forEach {
|
|
||||||
pages.add(Page(pages.size, "$url/$series-chapter-$chapter$index-page-${pages.size + 1}.html"))
|
|
||||||
}
|
|
||||||
pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun imageUrlParse(document: Document): String = document.select("img.CurImage").attr("src")
|
|
||||||
|
|
||||||
override fun latestUpdatesNextPageSelector() = "button.requestMore"
|
|
||||||
|
|
||||||
override fun latestUpdatesSelector(): String = "a.latestSeries"
|
|
||||||
|
|
||||||
override fun latestUpdatesRequest(page: Int): Request {
|
|
||||||
val url = "http://mangaseeonline.net/home/latest.request.php"
|
|
||||||
val (body, requestUrl) = convertQueryToPost(page, url)
|
|
||||||
return POST(requestUrl, catalogHeaders, body.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesFromElement(element: Element): SManga {
|
|
||||||
val manga = SManga.create()
|
|
||||||
element.select("a.latestSeries").first().let {
|
|
||||||
val chapterUrl = it.attr("href")
|
|
||||||
val indexOfMangaUrl = chapterUrl.indexOf("-chapter-")
|
|
||||||
val indexOfLastPath = chapterUrl.lastIndexOf("/")
|
|
||||||
val mangaUrl = chapterUrl.substring(indexOfLastPath, indexOfMangaUrl)
|
|
||||||
val defaultText = it.select("p.clamp2").text()
|
|
||||||
val m = recentUpdatesPattern.matcher(defaultText)
|
|
||||||
val title = if (m.matches()) m.group(1) else defaultText
|
|
||||||
manga.setUrlWithoutDomain("/manga" + mangaUrl)
|
|
||||||
manga.title = title
|
|
||||||
}
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
private class Sort : Filter.Sort("Sort", arrayOf("Alphabetically", "Date updated", "Popularity"), Filter.Sort.Selection(2, false))
|
|
||||||
private class Genre(name: String) : Filter.TriState(name)
|
|
||||||
private class TextField(name: String, val key: String) : Filter.Text(name)
|
|
||||||
private class SelectField(name: String, val key: String, values: Array<String>, state: Int = 0) : Filter.Select<String>(name, values, state)
|
|
||||||
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
|
|
||||||
|
|
||||||
override fun getFilterList() = FilterList(
|
|
||||||
TextField("Years", "year"),
|
|
||||||
TextField("Author", "author"),
|
|
||||||
SelectField("Scan Status", "status", arrayOf("Any", "Complete", "Discontinued", "Hiatus", "Incomplete", "Ongoing")),
|
|
||||||
SelectField("Publish Status", "pstatus", arrayOf("Any", "Cancelled", "Complete", "Discontinued", "Hiatus", "Incomplete", "Ongoing", "Unfinished")),
|
|
||||||
SelectField("Type", "type", arrayOf("Any", "Doujinshi", "Manga", "Manhua", "Manhwa", "OEL", "One-shot")),
|
|
||||||
Sort(),
|
|
||||||
GenreList(getGenreList())
|
|
||||||
)
|
|
||||||
|
|
||||||
// [...document.querySelectorAll("label.triStateCheckBox input")].map(el => `Filter("${el.getAttribute('name')}", "${el.nextSibling.textContent.trim()}")`).join(',\n')
|
|
||||||
// http://mangasee.co/advanced-search/
|
|
||||||
private fun getGenreList() = listOf(
|
|
||||||
Genre("Action"),
|
|
||||||
Genre("Adult"),
|
|
||||||
Genre("Adventure"),
|
|
||||||
Genre("Comedy"),
|
|
||||||
Genre("Doujinshi"),
|
|
||||||
Genre("Drama"),
|
|
||||||
Genre("Ecchi"),
|
|
||||||
Genre("Fantasy"),
|
|
||||||
Genre("Gender Bender"),
|
|
||||||
Genre("Harem"),
|
|
||||||
Genre("Hentai"),
|
|
||||||
Genre("Historical"),
|
|
||||||
Genre("Horror"),
|
|
||||||
Genre("Josei"),
|
|
||||||
Genre("Lolicon"),
|
|
||||||
Genre("Martial Arts"),
|
|
||||||
Genre("Mature"),
|
|
||||||
Genre("Mecha"),
|
|
||||||
Genre("Mystery"),
|
|
||||||
Genre("Psychological"),
|
|
||||||
Genre("Romance"),
|
|
||||||
Genre("School Life"),
|
|
||||||
Genre("Sci-fi"),
|
|
||||||
Genre("Seinen"),
|
|
||||||
Genre("Shotacon"),
|
|
||||||
Genre("Shoujo"),
|
|
||||||
Genre("Shoujo Ai"),
|
|
||||||
Genre("Shounen"),
|
|
||||||
Genre("Shounen Ai"),
|
|
||||||
Genre("Slice of Life"),
|
|
||||||
Genre("Smut"),
|
|
||||||
Genre("Sports"),
|
|
||||||
Genre("Supernatural"),
|
|
||||||
Genre("Tragedy"),
|
|
||||||
Genre("Yaoi"),
|
|
||||||
Genre("Yuri")
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
|
@ -1,224 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.source.online.english
|
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.network.GET
|
|
||||||
import eu.kanade.tachiyomi.network.POST
|
|
||||||
import eu.kanade.tachiyomi.source.model.*
|
|
||||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
|
||||||
import okhttp3.Headers
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import org.jsoup.nodes.Document
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
class Readmangatoday : ParsedHttpSource() {
|
|
||||||
|
|
||||||
override val id: Long = 8
|
|
||||||
|
|
||||||
override val name = "ReadMangaToday"
|
|
||||||
|
|
||||||
override val baseUrl = "https://www.readmng.com"
|
|
||||||
|
|
||||||
override val lang = "en"
|
|
||||||
|
|
||||||
override val supportsLatest = true
|
|
||||||
|
|
||||||
override val client: OkHttpClient get() = network.cloudflareClient
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search only returns data with this set
|
|
||||||
*/
|
|
||||||
override fun headersBuilder() = Headers.Builder().apply {
|
|
||||||
add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)")
|
|
||||||
add("X-Requested-With", "XMLHttpRequest")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaRequest(page: Int): Request {
|
|
||||||
return GET("$baseUrl/hot-manga/$page", headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesRequest(page: Int): Request {
|
|
||||||
return GET("$baseUrl/latest-releases/$page", headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaSelector() = "div.hot-manga > div.style-list > div.box"
|
|
||||||
|
|
||||||
override fun latestUpdatesSelector() = "div.hot-manga > div.style-grid > div.box"
|
|
||||||
|
|
||||||
override fun popularMangaFromElement(element: Element): SManga {
|
|
||||||
val manga = SManga.create()
|
|
||||||
element.select("div.title > h2 > a").first().let {
|
|
||||||
manga.setUrlWithoutDomain(it.attr("href"))
|
|
||||||
manga.title = it.attr("title")
|
|
||||||
}
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesFromElement(element: Element): SManga {
|
|
||||||
return popularMangaFromElement(element)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = "div.hot-manga > ul.pagination > li > a:contains(»)"
|
|
||||||
|
|
||||||
override fun latestUpdatesNextPageSelector() = "div.hot-manga > ul.pagination > li > a:contains(»)"
|
|
||||||
|
|
||||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
|
||||||
val builder = okhttp3.FormBody.Builder()
|
|
||||||
builder.add("manga-name", query)
|
|
||||||
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
|
||||||
when (filter) {
|
|
||||||
is TextField -> builder.add(filter.key, filter.state)
|
|
||||||
is Type -> builder.add("type", arrayOf("all", "japanese", "korean", "chinese")[filter.state])
|
|
||||||
is Status -> builder.add("status", arrayOf("both", "completed", "ongoing")[filter.state])
|
|
||||||
is GenreList -> filter.state.forEach { genre ->
|
|
||||||
when (genre.state) {
|
|
||||||
Filter.TriState.STATE_INCLUDE -> builder.add("include[]", genre.id.toString())
|
|
||||||
Filter.TriState.STATE_EXCLUDE -> builder.add("exclude[]", genre.id.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return POST("$baseUrl/service/advanced_search", headers, builder.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun searchMangaSelector() = "div.style-list > div.box"
|
|
||||||
|
|
||||||
override fun searchMangaFromElement(element: Element): SManga {
|
|
||||||
val manga = SManga.create()
|
|
||||||
element.select("div.title > h2 > a").first().let {
|
|
||||||
manga.setUrlWithoutDomain(it.attr("href"))
|
|
||||||
manga.title = it.attr("title")
|
|
||||||
}
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun searchMangaNextPageSelector() = "div.next-page > a.next"
|
|
||||||
|
|
||||||
override fun mangaDetailsParse(document: Document): SManga {
|
|
||||||
val detailElement = document.select("div.movie-meta").first()
|
|
||||||
val genreElement = detailElement.select("dl.dl-horizontal > dd:eq(5) a")
|
|
||||||
|
|
||||||
val manga = SManga.create()
|
|
||||||
manga.author = document.select("ul.cast-list li.director > ul a").first()?.text()
|
|
||||||
manga.artist = document.select("ul.cast-list li:not(.director) > ul a").first()?.text()
|
|
||||||
manga.description = detailElement.select("li.movie-detail").first()?.text()
|
|
||||||
manga.status = detailElement.select("dl.dl-horizontal > dd:eq(3)").first()?.text().orEmpty().let { parseStatus(it) }
|
|
||||||
manga.thumbnail_url = detailElement.select("img.img-responsive").first()?.attr("src")
|
|
||||||
|
|
||||||
var genres = mutableListOf<String>()
|
|
||||||
genreElement?.forEach { genres.add(it.text()) }
|
|
||||||
manga.genre = genres.joinToString(", ")
|
|
||||||
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseStatus(status: String) = when {
|
|
||||||
status.contains("Ongoing") -> SManga.ONGOING
|
|
||||||
status.contains("Completed") -> SManga.COMPLETED
|
|
||||||
else -> SManga.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun chapterListSelector() = "ul.chp_lst > li"
|
|
||||||
|
|
||||||
override fun chapterFromElement(element: Element): SChapter {
|
|
||||||
val urlElement = element.select("a").first()
|
|
||||||
|
|
||||||
val chapter = SChapter.create()
|
|
||||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
|
||||||
chapter.name = urlElement.select("span.val").text()
|
|
||||||
chapter.date_upload = element.select("span.dte").first()?.text()?.let { parseChapterDate(it) } ?: 0
|
|
||||||
return chapter
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseChapterDate(date: String): Long {
|
|
||||||
val dateWords: List<String> = date.split(" ")
|
|
||||||
|
|
||||||
if (dateWords.size == 3) {
|
|
||||||
val timeAgo = Integer.parseInt(dateWords[0])
|
|
||||||
val date: Calendar = Calendar.getInstance()
|
|
||||||
|
|
||||||
if (dateWords[1].contains("Minute")) {
|
|
||||||
date.add(Calendar.MINUTE, -timeAgo)
|
|
||||||
} else if (dateWords[1].contains("Hour")) {
|
|
||||||
date.add(Calendar.HOUR_OF_DAY, -timeAgo)
|
|
||||||
} else if (dateWords[1].contains("Day")) {
|
|
||||||
date.add(Calendar.DAY_OF_YEAR, -timeAgo)
|
|
||||||
} else if (dateWords[1].contains("Week")) {
|
|
||||||
date.add(Calendar.WEEK_OF_YEAR, -timeAgo)
|
|
||||||
} else if (dateWords[1].contains("Month")) {
|
|
||||||
date.add(Calendar.MONTH, -timeAgo)
|
|
||||||
} else if (dateWords[1].contains("Year")) {
|
|
||||||
date.add(Calendar.YEAR, -timeAgo)
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.timeInMillis
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pageListParse(document: Document): List<Page> {
|
|
||||||
val pages = mutableListOf<Page>()
|
|
||||||
document.select("ul.list-switcher-2 > li > select.jump-menu").first().getElementsByTag("option").forEach {
|
|
||||||
pages.add(Page(pages.size, it.attr("value")))
|
|
||||||
}
|
|
||||||
pages.getOrNull(0)?.imageUrl = imageUrlParse(document)
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun imageUrlParse(document: Document) = document.select("#chapter_img").first().attr("src")
|
|
||||||
|
|
||||||
private class Status : Filter.TriState("Completed")
|
|
||||||
private class Genre(name: String, val id: Int) : Filter.TriState(name)
|
|
||||||
private class TextField(name: String, val key: String) : Filter.Text(name)
|
|
||||||
private class Type : Filter.Select<String>("Type", arrayOf("All", "Japanese Manga", "Korean Manhwa", "Chinese Manhua"))
|
|
||||||
private class GenreList(genres: List<Genre>) : Filter.Group<Genre>("Genres", genres)
|
|
||||||
|
|
||||||
override fun getFilterList() = FilterList(
|
|
||||||
TextField("Author", "author-name"),
|
|
||||||
TextField("Artist", "artist-name"),
|
|
||||||
Type(),
|
|
||||||
Status(),
|
|
||||||
GenreList(getGenreList())
|
|
||||||
)
|
|
||||||
|
|
||||||
// [...document.querySelectorAll("ul.manga-cat span")].map(el => `Genre("${el.nextSibling.textContent.trim()}", ${el.getAttribute('data-id')})`).join(',\n')
|
|
||||||
// http://www.readmanga.today/advanced-search
|
|
||||||
private fun getGenreList() = listOf(
|
|
||||||
Genre("Action", 2),
|
|
||||||
Genre("Adventure", 4),
|
|
||||||
Genre("Comedy", 5),
|
|
||||||
Genre("Doujinshi", 6),
|
|
||||||
Genre("Drama", 7),
|
|
||||||
Genre("Ecchi", 8),
|
|
||||||
Genre("Fantasy", 9),
|
|
||||||
Genre("Gender Bender", 10),
|
|
||||||
Genre("Harem", 11),
|
|
||||||
Genre("Historical", 12),
|
|
||||||
Genre("Horror", 13),
|
|
||||||
Genre("Josei", 14),
|
|
||||||
Genre("Lolicon", 15),
|
|
||||||
Genre("Martial Arts", 16),
|
|
||||||
Genre("Mature", 17),
|
|
||||||
Genre("Mecha", 18),
|
|
||||||
Genre("Mystery", 19),
|
|
||||||
Genre("One shot", 20),
|
|
||||||
Genre("Psychological", 21),
|
|
||||||
Genre("Romance", 22),
|
|
||||||
Genre("School Life", 23),
|
|
||||||
Genre("Sci-fi", 24),
|
|
||||||
Genre("Seinen", 25),
|
|
||||||
Genre("Shotacon", 26),
|
|
||||||
Genre("Shoujo", 27),
|
|
||||||
Genre("Shoujo Ai", 28),
|
|
||||||
Genre("Shounen", 29),
|
|
||||||
Genre("Shounen Ai", 30),
|
|
||||||
Genre("Slice of Life", 31),
|
|
||||||
Genre("Smut", 32),
|
|
||||||
Genre("Sports", 33),
|
|
||||||
Genre("Supernatural", 34),
|
|
||||||
Genre("Tragedy", 35),
|
|
||||||
Genre("Yaoi", 36),
|
|
||||||
Genre("Yuri", 37)
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,122 +0,0 @@
|
|||||||
package eu.kanade.tachiyomi.source.online.german
|
|
||||||
|
|
||||||
import eu.kanade.tachiyomi.network.GET
|
|
||||||
import eu.kanade.tachiyomi.source.model.FilterList
|
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
|
||||||
import eu.kanade.tachiyomi.source.model.SChapter
|
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
|
||||||
import eu.kanade.tachiyomi.source.online.ParsedHttpSource
|
|
||||||
import okhttp3.Request
|
|
||||||
import org.jsoup.nodes.Document
|
|
||||||
import org.jsoup.nodes.Element
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
|
|
||||||
class WieManga : ParsedHttpSource() {
|
|
||||||
|
|
||||||
override val id: Long = 10
|
|
||||||
|
|
||||||
override val name = "Wie Manga!"
|
|
||||||
|
|
||||||
override val baseUrl = "http://www.wiemanga.com"
|
|
||||||
|
|
||||||
override val lang = "de"
|
|
||||||
|
|
||||||
override val supportsLatest = true
|
|
||||||
|
|
||||||
override fun popularMangaSelector() = ".booklist td > div"
|
|
||||||
|
|
||||||
override fun latestUpdatesSelector() = ".booklist td > div"
|
|
||||||
|
|
||||||
override fun popularMangaRequest(page: Int): Request {
|
|
||||||
return GET("$baseUrl/list/Hot-Book/", headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesRequest(page: Int): Request {
|
|
||||||
return GET("$baseUrl/list/New-Update/", headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaFromElement(element: Element): SManga {
|
|
||||||
val image = element.select("dt img")
|
|
||||||
val title = element.select("dd a:first-child")
|
|
||||||
|
|
||||||
val manga = SManga.create()
|
|
||||||
manga.setUrlWithoutDomain(title.attr("href"))
|
|
||||||
manga.title = title.text()
|
|
||||||
manga.thumbnail_url = image.attr("src")
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun latestUpdatesFromElement(element: Element): SManga {
|
|
||||||
return popularMangaFromElement(element)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun popularMangaNextPageSelector() = null
|
|
||||||
|
|
||||||
override fun latestUpdatesNextPageSelector() = null
|
|
||||||
|
|
||||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
|
||||||
return GET("$baseUrl/search/?wd=$query", headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun searchMangaSelector() = ".searchresult td > div"
|
|
||||||
|
|
||||||
override fun searchMangaFromElement(element: Element): SManga {
|
|
||||||
val image = element.select(".resultimg img")
|
|
||||||
val title = element.select(".resultbookname")
|
|
||||||
|
|
||||||
val manga = SManga.create()
|
|
||||||
manga.setUrlWithoutDomain(title.attr("href"))
|
|
||||||
manga.title = title.text()
|
|
||||||
manga.thumbnail_url = image.attr("src")
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun searchMangaNextPageSelector() = ".pagetor a.l"
|
|
||||||
|
|
||||||
override fun mangaDetailsParse(document: Document): SManga {
|
|
||||||
val imageElement = document.select(".bookmessgae tr > td:nth-child(1)").first()
|
|
||||||
val infoElement = document.select(".bookmessgae tr > td:nth-child(2)").first()
|
|
||||||
|
|
||||||
val manga = SManga.create()
|
|
||||||
manga.author = infoElement.select("dd:nth-of-type(2) a").first()?.text()
|
|
||||||
manga.artist = infoElement.select("dd:nth-of-type(3) a").first()?.text()
|
|
||||||
manga.description = infoElement.select("dl > dt:last-child").first()?.text()?.replaceFirst("Beschreibung", "")
|
|
||||||
manga.thumbnail_url = imageElement.select("img").first()?.attr("src")
|
|
||||||
|
|
||||||
if (manga.author == "RSS")
|
|
||||||
manga.author = null
|
|
||||||
|
|
||||||
if (manga.artist == "RSS")
|
|
||||||
manga.artist = null
|
|
||||||
return manga
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun chapterListSelector() = ".chapterlist tr:not(:first-child)"
|
|
||||||
|
|
||||||
override fun chapterFromElement(element: Element): SChapter {
|
|
||||||
val urlElement = element.select(".col1 a").first()
|
|
||||||
val dateElement = element.select(".col3 a").first()
|
|
||||||
|
|
||||||
val chapter = SChapter.create()
|
|
||||||
chapter.setUrlWithoutDomain(urlElement.attr("href"))
|
|
||||||
chapter.name = urlElement.text()
|
|
||||||
chapter.date_upload = dateElement?.text()?.let { parseChapterDate(it) } ?: 0
|
|
||||||
return chapter
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseChapterDate(date: String): Long {
|
|
||||||
return SimpleDateFormat("yyyy-MM-dd hh:mm:ss").parse(date).time
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun pageListParse(document: Document): List<Page> {
|
|
||||||
val pages = mutableListOf<Page>()
|
|
||||||
|
|
||||||
document.select("select#page").first().select("option").forEach {
|
|
||||||
pages.add(Page(pages.size, it.attr("value")))
|
|
||||||
}
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun imageUrlParse(document: Document) = document.select("img#comicpic").first().attr("src")
|
|
||||||
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user