Merge pull request #146 from CarlosEsco/MD2

Initial tracking changes
This commit is contained in:
Jays2Kings 2020-03-15 14:14:37 -07:00 committed by GitHub
commit 9a044e9037
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 1467 additions and 1534 deletions

View File

@ -135,7 +135,6 @@ dependencies {
val retrofit_version = "2.7.1" val retrofit_version = "2.7.1"
implementation("com.squareup.retrofit2:retrofit:$retrofit_version") implementation("com.squareup.retrofit2:retrofit:$retrofit_version")
implementation("com.squareup.retrofit2:converter-gson:$retrofit_version") implementation("com.squareup.retrofit2:converter-gson:$retrofit_version")
implementation("com.squareup.retrofit2:adapter-rxjava:$retrofit_version")
// JSON // JSON
implementation("com.google.code.gson:gson:2.8.6") implementation("com.google.code.gson:gson:2.8.6")

View File

@ -181,7 +181,7 @@ class BackupRestoreService : Service() {
*/ */
private suspend fun restoreBackup(uri: Uri) { private suspend fun restoreBackup(uri: Uri) {
val reader = JsonReader(contentResolver.openInputStream(uri)!!.bufferedReader()) val reader = JsonReader(contentResolver.openInputStream(uri)!!.bufferedReader())
val json = JsonParser().parse(reader).asJsonObject val json = JsonParser.parseReader(reader).asJsonObject
// Get parser version // Get parser version
val version = json.get(VERSION)?.asInt ?: 1 val version = json.get(VERSION)?.asInt ?: 1
@ -296,16 +296,16 @@ class BackupRestoreService : Service() {
* @param manga manga that needs updating. * @param manga manga that needs updating.
* @param tracks list containing tracks from restore file. * @param tracks list containing tracks from restore file.
*/ */
private fun trackingFetch(manga: Manga, tracks: List<Track>) { private suspend fun trackingFetch(manga: Manga, tracks: List<Track>) {
tracks.forEach { track -> tracks.forEach { track ->
val service = trackManager.getService(track.sync_id) val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged) { if (service != null && service.isLogged) {
service.refresh(track) try {
.doOnNext { db.insertTrack(it).executeAsBlocking() } service.refresh(track)
.onErrorReturn { db.insertTrack(track).executeAsBlocking()
errors.add("${manga.title} - ${it.message}") }catch (e : Exception){
track errors.add("${manga.title} - ${e.message}")
} }
} else { } else {
errors.add("${manga.title} - ${service?.name} not logged in") errors.add("${manga.title} - ${service?.name} not logged in")
val notLoggedIn = getString(R.string.not_logged_into, service?.name) val notLoggedIn = getString(R.string.not_logged_into, service?.name)

View File

@ -64,11 +64,11 @@ import java.util.concurrent.atomic.AtomicInteger
* destroyed. * destroyed.
*/ */
class LibraryUpdateService( class LibraryUpdateService(
val db: DatabaseHelper = Injekt.get(), val db: DatabaseHelper = Injekt.get(),
val sourceManager: SourceManager = Injekt.get(), val sourceManager: SourceManager = Injekt.get(),
val preferences: PreferencesHelper = Injekt.get(), val preferences: PreferencesHelper = Injekt.get(),
val downloadManager: DownloadManager = Injekt.get(), val downloadManager: DownloadManager = Injekt.get(),
val trackManager: TrackManager = Injekt.get() val trackManager: TrackManager = Injekt.get()
) : Service() { ) : Service() {
/** /**
@ -81,7 +81,6 @@ class LibraryUpdateService(
*/ */
private var subscription: Subscription? = null private var subscription: Subscription? = null
/** /**
* Pending intent of action that cancels the library update * Pending intent of action that cancels the library update
*/ */
@ -96,7 +95,7 @@ class LibraryUpdateService(
BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher) BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
} }
private var job:Job? = null private var job: Job? = null
private val mangaToUpdate = mutableListOf<LibraryManga>() private val mangaToUpdate = mutableListOf<LibraryManga>()
@ -108,14 +107,19 @@ class LibraryUpdateService(
/** /**
* Cached progress notification to avoid creating a lot. * Cached progress notification to avoid creating a lot.
*/ */
private val progressNotification by lazy { NotificationCompat.Builder(this, Notifications.CHANNEL_LIBRARY) private val progressNotification by lazy {
NotificationCompat.Builder(this, Notifications.CHANNEL_LIBRARY)
.setContentTitle(getString(R.string.app_name)) .setContentTitle(getString(R.string.app_name))
.setSmallIcon(R.drawable.ic_refresh_white_24dp_img) .setSmallIcon(R.drawable.ic_refresh_white_24dp_img)
.setLargeIcon(notificationBitmap) .setLargeIcon(notificationBitmap)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setColor(ContextCompat.getColor(this, R.color.colorAccent)) .setColor(ContextCompat.getColor(this, R.color.colorAccent))
.addAction(R.drawable.ic_clear_grey_24dp_img, getString(android.R.string.cancel), cancelIntent) .addAction(
R.drawable.ic_clear_grey_24dp_img,
getString(android.R.string.cancel),
cancelIntent
)
} }
/** /**
@ -172,8 +176,7 @@ class LibraryUpdateService(
} else { } else {
context.startForegroundService(intent) context.startForegroundService(intent)
} }
} } else {
else {
if (target == Target.CHAPTERS) category?.id?.let { if (target == Target.CHAPTERS) category?.id?.let {
instance?.addCategory(it) instance?.addCategory(it)
} }
@ -190,7 +193,7 @@ class LibraryUpdateService(
context.stopService(Intent(context, LibraryUpdateService::class.java)) context.stopService(Intent(context, LibraryUpdateService::class.java))
} }
private var listener:LibraryServiceListener? = null private var listener: LibraryServiceListener? = null
fun setListener(listener: LibraryServiceListener) { fun setListener(listener: LibraryServiceListener) {
this.listener = listener this.listener = listener
@ -212,7 +215,8 @@ class LibraryUpdateService(
val selectedScheme = preferences.libraryUpdatePrioritization().getOrDefault() val selectedScheme = preferences.libraryUpdatePrioritization().getOrDefault()
val mangas = val mangas =
getMangaToUpdate(categoryId, Target.CHAPTERS).sortedWith( getMangaToUpdate(categoryId, Target.CHAPTERS).sortedWith(
rankingScheme[selectedScheme]) rankingScheme[selectedScheme]
)
categoryIds.add(categoryId) categoryIds.add(categoryId)
addManga(mangas) addManga(mangas)
} }
@ -228,9 +232,9 @@ class LibraryUpdateService(
var listToUpdate = if (categoryId != -1) { var listToUpdate = if (categoryId != -1) {
categoryIds.add(categoryId) categoryIds.add(categoryId)
db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId } db.getLibraryMangas().executeAsBlocking().filter { it.category == categoryId }
} } else {
else { val categoriesToUpdate =
val categoriesToUpdate = preferences.libraryUpdateCategories().getOrDefault().map(String::toInt) preferences.libraryUpdateCategories().getOrDefault().map(String::toInt)
categoryIds.addAll(categoriesToUpdate) categoryIds.addAll(categoriesToUpdate)
if (categoriesToUpdate.isNotEmpty()) if (categoriesToUpdate.isNotEmpty())
db.getLibraryMangas().executeAsBlocking() db.getLibraryMangas().executeAsBlocking()
@ -259,7 +263,8 @@ class LibraryUpdateService(
super.onCreate() super.onCreate()
startForeground(Notifications.ID_LIBRARY_PROGRESS, progressNotification.build()) startForeground(Notifications.ID_LIBRARY_PROGRESS, progressNotification.build())
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "LibraryUpdateService:WakeLock") PowerManager.PARTIAL_WAKE_LOCK, "LibraryUpdateService:WakeLock"
)
wakeLock.acquire(TimeUnit.MINUTES.toMillis(30)) wakeLock.acquire(TimeUnit.MINUTES.toMillis(30))
} }
@ -297,7 +302,7 @@ class LibraryUpdateService(
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) return START_NOT_STICKY if (intent == null) return START_NOT_STICKY
val target = intent.getSerializableExtra(KEY_TARGET) as? Target val target = intent.getSerializableExtra(KEY_TARGET) as? Target
?: return START_NOT_STICKY ?: return START_NOT_STICKY
// Unsubscribe from any previous subscription if needed. // Unsubscribe from any previous subscription if needed.
subscription?.unsubscribe() subscription?.unsubscribe()
@ -307,41 +312,44 @@ class LibraryUpdateService(
val mangaList = val mangaList =
getMangaToUpdate(intent, target).sortedWith(rankingScheme[selectedScheme]) getMangaToUpdate(intent, target).sortedWith(rankingScheme[selectedScheme])
// 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.
if (target == Target.CHAPTERS) { if (target == Target.DETAILS) {
updateChapters(mangaList, startId)
}
else {
// Update either chapter list or manga details. // Update either chapter list or manga details.
subscription = Observable.defer { subscription = Observable.defer {
when (target) { updateDetails(mangaList)
Target.DETAILS -> updateDetails(mangaList)
else -> updateTrackings(mangaList)
}
}.subscribeOn(Schedulers.io()).subscribe({}, { }.subscribeOn(Schedulers.io()).subscribe({}, {
Timber.e(it) Timber.e(it)
stopSelf(startId) stopSelf(startId)
}, { }, {
stopSelf(startId) stopSelf(startId)
}) })
} else {
launchTarget(target, mangaList, startId)
} }
return START_REDELIVER_INTENT return START_REDELIVER_INTENT
} }
private fun updateChapters(mangaToAdd: List<LibraryManga>, startId: Int) { private fun launchTarget(target: Target, mangaToAdd: List<LibraryManga>, startId: Int) {
val handler = CoroutineExceptionHandler { _, exception -> val handler = CoroutineExceptionHandler { _, exception ->
Timber.e(exception) Timber.e(exception)
// Boolean to determine if user wants to automatically download new chapters.
stopSelf(startId) stopSelf(startId)
} }
job = GlobalScope.launch(handler) { if (target == Target.CHAPTERS) {
updateChaptersJob(mangaToAdd) job = GlobalScope.launch(handler) {
updateChaptersJob(mangaToAdd)
}
} else {
job = GlobalScope.launch(handler) {
updateTrackings(mangaToAdd)
}
} }
job?.invokeOnCompletion { stopSelf(startId) } job?.invokeOnCompletion { stopSelf(startId) }
} }
private suspend fun updateChaptersJob(mangaToAdd: List<LibraryManga>) { private suspend fun updateChaptersJob(mangaToAdd: List<LibraryManga>) {
// List containing categories that get included in downloads. // List containing categories that get included in downloads.
val categoriesToDownload = preferences.downloadNewCategories().getOrDefault().map(String::toInt) val categoriesToDownload =
preferences.downloadNewCategories().getOrDefault().map(String::toInt)
// Boolean to determine if user wants to automatically download new chapters. // Boolean to determine if user wants to automatically download new chapters.
val downloadNew = preferences.downloadNew().getOrDefault() val downloadNew = preferences.downloadNew().getOrDefault()
// Boolean to determine if DownloadManager has downloads // Boolean to determine if DownloadManager has downloads
@ -352,7 +360,7 @@ class LibraryUpdateService(
mangaToUpdate.addAll(mangaToAdd) mangaToUpdate.addAll(mangaToAdd)
while (count < mangaToUpdate.size) { while (count < mangaToUpdate.size) {
val shouldDownload = (downloadNew && (categoriesToDownload.isEmpty() || val shouldDownload = (downloadNew && (categoriesToDownload.isEmpty() ||
mangaToUpdate[count].category in categoriesToDownload)) mangaToUpdate[count].category in categoriesToDownload))
if (updateMangaChapters(mangaToUpdate[count], count, shouldDownload)) { if (updateMangaChapters(mangaToUpdate[count], count, shouldDownload)) {
hasDownloads = true hasDownloads = true
} }
@ -370,8 +378,7 @@ class LibraryUpdateService(
} }
} }
.subscribeOn(Schedulers.io()).subscribe {} .subscribeOn(Schedulers.io()).subscribe {}
} } else if (downloadNew && hasDownloads) {
else if (downloadNew && hasDownloads) {
DownloadService.start(this) DownloadService.start(this)
} }
} }
@ -379,8 +386,12 @@ class LibraryUpdateService(
cancelProgressNotification() cancelProgressNotification()
} }
private suspend fun updateMangaChapters(manga: LibraryManga, progess: Int, shouldDownload: Boolean): private suspend fun updateMangaChapters(
Boolean { manga: LibraryManga,
progess: Int,
shouldDownload: Boolean
):
Boolean {
try { try {
var hasDownloads = false var hasDownloads = false
if (job?.isCancelled == true) { if (job?.isCancelled == true) {
@ -389,7 +400,7 @@ class LibraryUpdateService(
showProgressNotification(manga, progess, mangaToUpdate.size) showProgressNotification(manga, progess, mangaToUpdate.size)
val source = sourceManager.get(manga.source) as? HttpSource ?: return false val source = sourceManager.get(manga.source) as? HttpSource ?: return false
val fetchedChapters = withContext(Dispatchers.IO) { val fetchedChapters = withContext(Dispatchers.IO) {
source.fetchChapterList(manga).toBlocking().single() source.fetchChapterList(manga).toBlocking().single()
} ?: emptyList() } ?: emptyList()
if (fetchedChapters.isNotEmpty()) { if (fetchedChapters.isNotEmpty()) {
val newChapters = syncChaptersWithSource(db, fetchedChapters, manga, source) val newChapters = syncChaptersWithSource(db, fetchedChapters, manga, source)
@ -406,8 +417,7 @@ class LibraryUpdateService(
) )
} }
return hasDownloads return hasDownloads
} } catch (e: Exception) {
catch (e: Exception) {
Timber.e("Failed updating: ${manga.title}: $e") Timber.e("Failed updating: ${manga.title}: $e")
return false return false
} }
@ -433,7 +443,7 @@ class LibraryUpdateService(
fun updateManga(manga: Manga): Observable<Pair<List<Chapter>, List<Chapter>>> { fun updateManga(manga: Manga): Observable<Pair<List<Chapter>, List<Chapter>>> {
val source = sourceManager.get(manga.source) as? HttpSource ?: return Observable.empty() val source = sourceManager.get(manga.source) as? HttpSource ?: return Observable.empty()
return source.fetchChapterList(manga) return source.fetchChapterList(manga)
.map { syncChaptersWithSource(db, it, manga, source) } .map { syncChaptersWithSource(db, it, manga, source) }
} }
/** /**
@ -449,62 +459,57 @@ class LibraryUpdateService(
// Emit each manga and update it sequentially. // Emit each manga and update it sequentially.
return Observable.from(mangaToUpdate) return Observable.from(mangaToUpdate)
// Notify manga that will update. // Notify manga that will update.
.doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) } .doOnNext { showProgressNotification(it, count.andIncrement, mangaToUpdate.size) }
// Update the details of the manga. // Update the details of the manga.
.concatMap { manga -> .concatMap { manga ->
val source = sourceManager.get(manga.source) as? HttpSource val source = sourceManager.get(manga.source) as? HttpSource
?: return@concatMap Observable.empty<LibraryManga>() ?: return@concatMap Observable.empty<LibraryManga>()
source.fetchMangaDetails(manga) source.fetchMangaDetails(manga)
.map { networkManga -> .map { networkManga ->
val thumbnailUrl = manga.thumbnail_url val thumbnailUrl = manga.thumbnail_url
manga.copyFrom(networkManga) manga.copyFrom(networkManga)
db.insertManga(manga).executeAsBlocking() db.insertManga(manga).executeAsBlocking()
if (thumbnailUrl != networkManga.thumbnail_url) if (thumbnailUrl != networkManga.thumbnail_url)
MangaImpl.setLastCoverFetch(manga.id!!, Date().time) MangaImpl.setLastCoverFetch(manga.id!!, Date().time)
manga manga
} }
.onErrorReturn { manga } .onErrorReturn { manga }
} }
.doOnCompleted { .doOnCompleted {
cancelProgressNotification() cancelProgressNotification()
} }
} }
/** /**
* Method that updates the metadata of the connected tracking services. It's called in a * Method that updates the metadata of the connected tracking services. It's called in a
* background thread, so it's safe to do heavy operations or network calls here. * background thread, so it's safe to do heavy operations or network calls here.
*/ */
private fun updateTrackings(mangaToUpdate: List<LibraryManga>): Observable<LibraryManga> {
private suspend fun updateTrackings(mangaToUpdate: List<LibraryManga>) {
// Initialize the variables holding the progress of the updates. // Initialize the variables holding the progress of the updates.
var count = 0 var count = 0
val loggedServices = trackManager.services.filter { it.isLogged } val loggedServices = trackManager.services.filter { it.isLogged }
// Emit each manga and update it sequentially. mangaToUpdate.forEach { manga ->
return Observable.from(mangaToUpdate) showProgressNotification(manga, count++, mangaToUpdate.size)
// Notify manga that will update.
.doOnNext { showProgressNotification(it, count++, mangaToUpdate.size) }
// Update the tracking details.
.concatMap { manga ->
val tracks = db.getTracks(manga).executeAsBlocking()
Observable.from(tracks) val tracks = db.getTracks(manga).executeAsBlocking()
.concatMap { track ->
val service = trackManager.getService(track.sync_id) tracks.forEach { track ->
if (service != null && service in loggedServices) { val service = trackManager.getService(track.sync_id)
service.refresh(track) if (service != null && service in loggedServices) {
.doOnNext { db.insertTrack(it).executeAsBlocking() } try {
.onErrorReturn { track } service.refresh(track)
} else { db.insertTrack(track).executeAsBlocking()
Observable.empty() } catch (e: Exception) {
} Timber.e(e)
} }
.map { manga }
}
.doOnCompleted {
cancelProgressNotification()
} }
}
}
cancelProgressNotification()
} }
/** /**
@ -515,10 +520,12 @@ class LibraryUpdateService(
* @param total the total progress. * @param total the total progress.
*/ */
private fun showProgressNotification(manga: Manga, current: Int, total: Int) { private fun showProgressNotification(manga: Manga, current: Int, total: Int) {
notificationManager.notify(Notifications.ID_LIBRARY_PROGRESS, progressNotification notificationManager.notify(
Notifications.ID_LIBRARY_PROGRESS, progressNotification
.setContentTitle(manga.currentTitle()) .setContentTitle(manga.currentTitle())
.setProgress(total, current, false) .setProgress(total, current, false)
.build()) .build()
)
} }
/** /**
@ -539,15 +546,17 @@ class LibraryUpdateService(
.asBitmap().load(manga).dontTransform().centerCrop().circleCrop() .asBitmap().load(manga).dontTransform().centerCrop().circleCrop()
.override(256, 256).submit().get() .override(256, 256).submit().get()
setLargeIcon(icon) setLargeIcon(icon)
} catch (e: Exception) {
} }
catch (e: Exception) { }
setGroupAlertBehavior(GROUP_ALERT_SUMMARY) setGroupAlertBehavior(GROUP_ALERT_SUMMARY)
setContentTitle(manga.currentTitle()) setContentTitle(manga.currentTitle())
color = ContextCompat.getColor(this@LibraryUpdateService, R.color.colorAccent) color = ContextCompat.getColor(this@LibraryUpdateService, R.color.colorAccent)
val chaptersNames = if (chapterNames.size > 5) { val chaptersNames = if (chapterNames.size > 5) {
"${chapterNames.take(4).joinToString(", ")}, " + "${chapterNames.take(4).joinToString(", ")}, " +
resources.getQuantityString(R.plurals.notification_and_n_more, resources.getQuantityString(
(chapterNames.size - 4), (chapterNames.size - 4)) R.plurals.notification_and_n_more,
(chapterNames.size - 4), (chapterNames.size - 4)
)
} else chapterNames.joinToString(", ") } else chapterNames.joinToString(", ")
setContentText(chaptersNames) setContentText(chaptersNames)
setStyle(NotificationCompat.BigTextStyle().bigText(chaptersNames)) setStyle(NotificationCompat.BigTextStyle().bigText(chaptersNames))
@ -558,41 +567,57 @@ class LibraryUpdateService(
this@LibraryUpdateService, manga, chapters.first() this@LibraryUpdateService, manga, chapters.first()
) )
) )
addAction(R.drawable.ic_glasses_black_24dp, getString(R.string.action_mark_as_read), addAction(
NotificationReceiver.markAsReadPendingBroadcast(this@LibraryUpdateService, R.drawable.ic_glasses_black_24dp, getString(R.string.action_mark_as_read),
manga, chapters, Notifications.ID_NEW_CHAPTERS)) NotificationReceiver.markAsReadPendingBroadcast(
addAction(R.drawable.ic_book_white_24dp, getString(R.string.action_view_chapters), this@LibraryUpdateService,
NotificationReceiver.openChapterPendingActivity(this@LibraryUpdateService, manga, chapters, Notifications.ID_NEW_CHAPTERS
manga, Notifications.ID_NEW_CHAPTERS)) )
)
addAction(
R.drawable.ic_book_white_24dp, getString(R.string.action_view_chapters),
NotificationReceiver.openChapterPendingActivity(
this@LibraryUpdateService,
manga, Notifications.ID_NEW_CHAPTERS
)
)
setAutoCancel(true) setAutoCancel(true)
}, manga.id.hashCode())) }, manga.id.hashCode()))
} }
NotificationManagerCompat.from(this).apply { NotificationManagerCompat.from(this).apply {
notify(Notifications.ID_NEW_CHAPTERS, notification(Notifications.CHANNEL_NEW_CHAPTERS) { notify(
setSmallIcon(R.drawable.ic_tachi) Notifications.ID_NEW_CHAPTERS,
setLargeIcon(notificationBitmap) notification(Notifications.CHANNEL_NEW_CHAPTERS) {
setContentTitle(getString(R.string.notification_new_chapters)) setSmallIcon(R.drawable.ic_tachi)
color = ContextCompat.getColor(applicationContext, R.color.colorAccent) setLargeIcon(notificationBitmap)
if (updates.size > 1) { setContentTitle(getString(R.string.notification_new_chapters))
setContentText(resources.getQuantityString(R.plurals color = ContextCompat.getColor(applicationContext, R.color.colorAccent)
.notification_new_chapters_text, if (updates.size > 1) {
updates.size, updates.size)) setContentText(
setStyle(NotificationCompat.BigTextStyle().bigText(updates.keys.joinToString("\n") { resources.getQuantityString(
it.currentTitle().chop(45) R.plurals
})) .notification_new_chapters_text,
} updates.size, updates.size
else { )
setContentText(updates.keys.first().currentTitle().chop(45)) )
} setStyle(
priority = NotificationCompat.PRIORITY_HIGH NotificationCompat.BigTextStyle()
setGroup(Notifications.GROUP_NEW_CHAPTERS) .bigText(updates.keys.joinToString("\n") {
setGroupAlertBehavior(GROUP_ALERT_SUMMARY) it.currentTitle().chop(45)
setGroupSummary(true) })
setContentIntent(getNotificationIntent()) )
setAutoCancel(true) } else {
}) setContentText(updates.keys.first().currentTitle().chop(45))
}
priority = NotificationCompat.PRIORITY_HIGH
setGroup(Notifications.GROUP_NEW_CHAPTERS)
setGroupAlertBehavior(GROUP_ALERT_SUMMARY)
setGroupSummary(true)
setContentIntent(getNotificationIntent())
setAutoCancel(true)
})
notifications.forEach { notifications.forEach {
notify(it.second, it.first) notify(it.second, it.first)

View File

@ -3,11 +3,11 @@ 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 import eu.kanade.tachiyomi.data.track.shikimori.Shikimori
import eu.kanade.tachiyomi.data.track.bangumi.Bangumi import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
class TrackManager(private val context: Context) { class TrackManager(context: Context) {
companion object { companion object {
const val MYANIMELIST = 1 const val MYANIMELIST = 1
@ -17,7 +17,7 @@ class TrackManager(private val context: Context) {
const val BANGUMI = 5 const val BANGUMI = 5
} }
val myAnimeList = Myanimelist(context, MYANIMELIST) val myAnimeList = MyAnimeList(context, MYANIMELIST)
val aniList = Anilist(context, ANILIST) val aniList = Anilist(context, ANILIST)

View File

@ -7,8 +7,6 @@ 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.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
abstract class TrackService(val id: Int) { abstract class TrackService(val id: Int) {
@ -39,17 +37,15 @@ abstract class TrackService(val id: Int) {
abstract fun displayScore(track: Track): String abstract fun displayScore(track: Track): String
abstract fun add(track: Track): Observable<Track> abstract suspend fun update(track: Track): Track
abstract fun update(track: Track): Observable<Track> abstract suspend fun bind(track: Track): Track
abstract fun bind(track: Track): Observable<Track> abstract suspend fun search(query: String): List<TrackSearch>
abstract fun search(query: String): Observable<List<TrackSearch>> abstract suspend fun refresh(track: Track): Track
abstract fun refresh(track: Track): Observable<Track> abstract suspend fun login(username: String, password: String): Boolean
abstract fun login(username: String, password: String): Completable
@CallSuper @CallSuper
open fun logout() { open fun logout() {

View File

@ -8,30 +8,11 @@ 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 timber.log.Timber
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 {
const val READING = 1
const val COMPLETED = 2
const val PAUSED = 3
const val DROPPED = 4
const val PLANNING = 5
const val REPEATING = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
const val POINT_100 = "POINT_100"
const val POINT_10 = "POINT_10"
const val POINT_10_DECIMAL = "POINT_10_DECIMAL"
const val POINT_5 = "POINT_5"
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()
@ -56,9 +37,7 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
override fun getLogoColor() = Color.rgb(18, 25, 35) override fun getLogoColor() = Color.rgb(18, 25, 35)
override fun getStatusList(): List<Int> { override fun getStatusList() = listOf(READING, PLANNING, COMPLETED, REPEATING, PAUSED, DROPPED)
return listOf(READING, PLANNING, COMPLETED, REPEATING, PAUSED, DROPPED)
}
override fun getStatus(status: Int): String = with(context) { override fun getStatus(status: Int): String = with(context) {
when (status) { when (status) {
@ -95,13 +74,13 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
// 100 point // 100 point
POINT_100 -> index.toFloat() POINT_100 -> index.toFloat()
// 5 stars // 5 stars
POINT_5 -> when { POINT_5 -> when (index) {
index == 0 -> 0f 0 -> 0f
else -> index * 20f - 10f else -> index * 20f - 10f
} }
// Smiley // Smiley
POINT_3 -> when { POINT_3 -> when (index) {
index == 0 -> 0f 0 -> 0f
else -> index * 25f + 10f else -> index * 25f + 10f
} }
// 10 point decimal // 10 point decimal
@ -114,8 +93,8 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
val score = track.score val score = track.score
return when (scorePreference.getOrDefault()) { return when (scorePreference.getOrDefault()) {
POINT_5 -> when { POINT_5 -> when (score) {
score == 0f -> "0 ★" 0f -> "0 ★"
else -> "${((score + 10) / 20).toInt()}" else -> "${((score + 10) / 20).toInt()}"
} }
POINT_3 -> when { POINT_3 -> when {
@ -128,68 +107,62 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
} }
} }
override fun add(track: Track): Observable<Track> { override suspend fun update(track: Track): Track {
return api.addLibManga(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 { val libManga = api.findLibManga(track, getUsername().toInt())
if (it == null) { ?: throw Exception("$track not found on user library")
throw Exception("$track not found on user library")
} track.library_id = libManga.library_id
track.library_id = it.library_id
api.updateLibManga(track)
}
} }
return api.updateLibManga(track) return api.updateLibraryManga(track)
} }
override fun bind(track: Track): Observable<Track> { override suspend fun bind(track: Track): Track {
return api.findLibManga(track, getUsername().toInt()) val remoteTrack = api.findLibManga(track, getUsername().toInt())
.flatMap { remoteTrack ->
if (remoteTrack != null) { return 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) api.addLibManga(track)
} }
}
} }
override fun search(query: String): Observable<List<TrackSearch>> { override suspend fun search(query: String) = api.search(query)
return api.search(query)
override suspend fun refresh(track: Track): Track {
val remoteTrack = api.getLibManga(track, getUsername().toInt())
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
return track
} }
override fun refresh(track: Track): Observable<Track> { override suspend fun login(username: String, password: String) = login(password)
return api.getLibManga(track, getUsername().toInt())
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
}
override fun login(username: String, password: String) = login(password) suspend fun login(token: String): Boolean {
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) ->
scorePreference.set(scoreType) return try {
saveCredentials(username.toString(), oauth.access_token) val currentUser = api.getCurrentUser()
}.doOnError{ scorePreference.set(currentUser.second)
logout() saveCredentials(currentUser.first.toString(), oauth.access_token)
}.toCompletable() true
} catch (e: Exception) {
Timber.e(e)
logout()
false
}
} }
override fun logout() { override fun logout() {
@ -206,9 +179,29 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
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) {
Timber.e(e)
null null
} }
} }
companion object {
const val READING = 1
const val COMPLETED = 2
const val PAUSED = 3
const val DROPPED = 4
const val PLANNING = 5
const val REPEATING = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
const val POINT_100 = "POINT_100"
const val POINT_10 = "POINT_10"
const val POINT_10_DECIMAL = "POINT_10_DECIMAL"
const val POINT_5 = "POINT_5"
const val POINT_3 = "POINT_3"
}
} }

View File

@ -11,25 +11,209 @@ 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.jsonType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import rx.Observable import okhttp3.Response
import java.util.Calendar import java.util.Calendar
import java.util.concurrent.TimeUnit
class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
private val parser = JsonParser()
private val jsonMime = "application/json; charset=utf-8".toMediaTypeOrNull()
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track): Observable<Track> { suspend fun addLibManga(track: Track): Track {
val query = """ return withContext(Dispatchers.IO) {
val variables = jsonObject(
"mangaId" to track.media_id,
"progress" to track.last_chapter_read,
"status" to track.toAnilistStatus()
)
val payload = jsonObject(
"query" to addToLibraryQuery(),
"variables" to variables
)
val body = payload.toString().toRequestBody(MediaType.jsonType())
val request = Request.Builder().url(apiUrl).post(body).build()
val netResponse = authClient.newCall(request).execute()
val responseBody = netResponse.body?.string().orEmpty()
netResponse.close()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = JsonParser.parseString(responseBody).obj
track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong
track
}
}
suspend fun updateLibraryManga(track: Track): Track {
return withContext(Dispatchers.IO) {
val variables = jsonObject(
"listId" to track.library_id,
"progress" to track.last_chapter_read,
"status" to track.toAnilistStatus(),
"score" to track.score.toInt()
)
val payload = jsonObject(
"query" to updateInLibraryQuery(),
"variables" to variables
)
val body = payload.toString().toRequestBody(MediaType.jsonType())
val request = Request.Builder().url(apiUrl).post(body).build()
val response = authClient.newCall(request).execute()
track
}
}
suspend fun search(search: String): List<TrackSearch> {
return withContext(Dispatchers.IO) {
val variables = jsonObject(
"query" to search
)
val payload = jsonObject(
"query" to searchQuery(),
"variables" to variables
)
val body = payload.toString().toRequestBody(MediaType.jsonType())
val request = Request.Builder().url(apiUrl).post(body).build()
val netResponse = authClient.newCall(request).execute()
val response = responseToJson(netResponse)
val media = response["data"]!!.obj["Page"].obj["mediaList"].array
val entries = media.map { jsonToALManga(it.obj) }
entries.map { it.toTrack() }
}
}
suspend fun findLibManga(track: Track, userid: Int): Track? {
return withContext(Dispatchers.IO) {
val variables = jsonObject(
"id" to userid,
"manga_id" to track.media_id
)
val payload = jsonObject(
"query" to findLibraryMangaQuery(),
"variables" to variables
)
val body = payload.toString().toRequestBody(MediaType.jsonType())
val request = Request.Builder().url(apiUrl).post(body).build()
val result = authClient.newCall(request).execute()
result.let { resp ->
val response = responseToJson(resp)
val media = response["data"]!!.obj["Page"].obj["mediaList"].array
val entries = media.map { jsonToALUserManga(it.obj) }
entries.firstOrNull()?.toTrack()
}
}
}
suspend fun getLibManga(track: Track, userid: Int): Track {
val remoteTrack = findLibManga(track, userid)
if (remoteTrack == null) {
throw Exception("Could not find manga")
} else {
return remoteTrack
}
}
fun createOAuth(token: String): OAuth {
return OAuth(
token,
"Bearer",
System.currentTimeMillis() + TimeUnit.DAYS.toMillis(365),
TimeUnit.DAYS.toMillis(365)
)
}
suspend fun getCurrentUser(): Pair<Int, String> {
return withContext(Dispatchers.IO) {
val payload = jsonObject(
"query" to currentUserQuery()
)
val body = payload.toString().toRequestBody(MediaType.jsonType())
val request = Request.Builder().url(apiUrl).post(body).build()
val netResponse = authClient.newCall(request).execute()
val response = responseToJson(netResponse)
val viewer = response["data"]!!.obj["Viewer"].obj
Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString)
}
}
private fun responseToJson(netResponse: Response): JsonObject {
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
return JsonParser.parseString(responseBody).obj
}
private fun jsonToALManga(struct: JsonObject): ALManga {
val date = try {
val date = Calendar.getInstance()
date.set(
struct["startDate"]["year"].nullInt ?: 0,
(struct["startDate"]["month"].nullInt ?: 0) - 1,
struct["startDate"]["day"].nullInt ?: 0
)
date.timeInMillis
} 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,
date,
struct["chapters"].nullInt ?: 0
)
}
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 {
private const val clientId = "385"
private const val apiUrl = "https://graphql.anilist.co/"
private const val baseUrl = "https://anilist.co/api/v2/"
private const val baseMangaUrl = "https://anilist.co/manga/"
fun mangaUrl(mediaId: Int): String {
return baseMangaUrl + mediaId
}
fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("response_type", "token")
.build()!!
fun addToLibraryQuery() = """
|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
@ -37,36 +221,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|} |}
|} |}
|""".trimMargin() |""".trimMargin()
val variables = jsonObject(
"mangaId" to track.media_id,
"progress" to track.last_chapter_read,
"status" to track.toAnilistStatus()
)
val payload = jsonObject(
"query" to query,
"variables" to variables
)
val body = payload.toString().toRequestBody(jsonMime)
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map { netResponse ->
val responseBody = netResponse.body?.string().orEmpty()
netResponse.close()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = parser.parse(responseBody).obj
track.library_id = response["data"]["SaveMediaListEntry"]["id"].asLong
track
}
}
fun updateLibManga(track: Track): Observable<Track> { fun updateInLibraryQuery() = """
val query = """
|mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) { |mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
|SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) { |SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) {
|id |id
@ -75,30 +231,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|} |}
|} |}
|""".trimMargin() |""".trimMargin()
val variables = jsonObject(
"listId" to track.library_id,
"progress" to track.last_chapter_read,
"status" to track.toAnilistStatus(),
"score" to track.score.toInt()
)
val payload = jsonObject(
"query" to query,
"variables" to variables
)
val body = payload.toString().toRequestBody(jsonMime)
val request = Request.Builder()
.url(apiUrl)
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map {
track
}
}
fun search(search: String): Observable<List<TrackSearch>> { fun searchQuery() = """
val query = """
|query Search(${'$'}query: String) { |query Search(${'$'}query: String) {
|Page (perPage: 50) { |Page (perPage: 50) {
|media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) { |media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
@ -122,37 +256,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|} |}
|} |}
|""".trimMargin() |""".trimMargin()
val variables = jsonObject(
"query" to search
)
val payload = jsonObject(
"query" to query,
"variables" to variables
)
val body = payload.toString().toRequestBody(jsonMime)
val request = Request.Builder()
.url(apiUrl)
.post(body)
.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).obj
val data = response["data"]!!.obj
val page = data["Page"].obj
val media = page["media"].array
val entries = media.map { jsonToALManga(it.obj) }
entries.map { it.toTrack() }
}
}
fun findLibraryMangaQuery() = """
fun findLibManga(track: Track, userid: Int): Observable<Track?> {
val query = """
|query (${'$'}id: Int!, ${'$'}manga_id: Int!) { |query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
|Page { |Page {
|mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) { |mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
@ -182,47 +287,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|} |}
|} |}
|""".trimMargin() |""".trimMargin()
val variables = jsonObject(
"id" to userid,
"manga_id" to track.media_id
)
val payload = jsonObject(
"query" to query,
"variables" to variables
)
val body = payload.toString().toRequestBody(jsonMime)
val request = Request.Builder()
.url(apiUrl)
.post(body)
.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).obj
val data = response["data"]!!.obj
val page = data["Page"].obj
val media = page["mediaList"].array
val entries = media.map { jsonToALUserManga(it.obj) }
entries.firstOrNull()?.toTrack()
} fun currentUserQuery() = """
}
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 getCurrentUser(): Observable<Pair<Int, String>> {
val query = """
|query User { |query User {
|Viewer { |Viewer {
|id |id
@ -232,62 +298,5 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|} |}
|} |}
|""".trimMargin() |""".trimMargin()
val payload = jsonObject(
"query" to query
)
val body = payload.toString().toRequestBody(jsonMime)
val request = Request.Builder()
.url(apiUrl)
.post(body)
.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).obj
val data = response["data"]!!.obj
val viewer = data["Viewer"].obj
Pair(viewer["id"].asInt, viewer["mediaListOptions"]["scoreFormat"].asString)
}
} }
private fun jsonToALManga(struct: JsonObject): ALManga {
val date = try {
val date = Calendar.getInstance()
date.set(struct["startDate"]["year"].nullInt ?: 0, (struct["startDate"]["month"].nullInt ?: 0) - 1,
struct["startDate"]["day"].nullInt ?: 0)
date.timeInMillis
} 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,
date, struct["chapters"].nullInt ?: 0)
}
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 {
private const val clientId = "385"
private const val clientUrl = "tachiyomi://anilist-auth"
private const val apiUrl = "https://graphql.anilist.co/"
private const val baseUrl = "https://anilist.co/api/v2/"
private const val baseMangaUrl = "https://anilist.co/manga/"
fun mangaUrl(mediaId: Int): String {
return baseMangaUrl + mediaId
}
fun authUrl() = Uri.parse("${baseUrl}oauth/authorize").buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("response_type", "token")
.build()
}
} }

View File

@ -4,7 +4,7 @@ import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor { class AnilistInterceptor(private val anilist: Anilist, private var token: String?) : Interceptor {
/** /**
* OAuth object used for authenticated requests. * OAuth object used for authenticated requests.

View File

@ -9,6 +9,15 @@ import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
data class OAuth(
val access_token: String,
val token_type: String,
val expires: Long,
val expires_in: Long) {
fun isExpired() = System.currentTimeMillis() > expires
}
data class ALManga( data class ALManga(
val media_id: Int, val media_id: Int,
val title_romaji: String, val title_romaji: String,
@ -56,7 +65,7 @@ data class ALUserManga(
total_chapters = manga.total_chapters total_chapters = manga.total_chapters
} }
fun toTrackStatus() = when (list_status) { private fun toTrackStatus() = when (list_status) {
"CURRENT" -> Anilist.READING "CURRENT" -> Anilist.READING
"COMPLETED" -> Anilist.COMPLETED "COMPLETED" -> Anilist.COMPLETED
"PAUSED" -> Anilist.PAUSED "PAUSED" -> Anilist.PAUSED

View File

@ -1,10 +0,0 @@
package eu.kanade.tachiyomi.data.track.anilist
data class OAuth(
val access_token: String,
val token_type: String,
val expires: Long,
val expires_in: Long) {
fun isExpired() = System.currentTimeMillis() > expires
}

View File

@ -1,7 +0,0 @@
package eu.kanade.tachiyomi.data.track.bangumi
data class Avatar(
val large: String? = "",
val medium: String? = "",
val small: String? = ""
)

View File

@ -7,8 +7,7 @@ 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 timber.log.Timber
import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class Bangumi(private val context: Context, id: Int) : TrackService(id) { class Bangumi(private val context: Context, id: Int) : TrackService(id) {
@ -29,55 +28,44 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
return track.score.toInt().toString() return track.score.toInt().toString()
} }
override fun add(track: Track): Observable<Track> { override suspend fun update(track: Track): Track {
return api.addLibManga(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 suspend fun bind(track: Track): Track {
return api.statusLibManga(track) val statusTrack = api.statusLibManga(track)
.flatMap { val remoteTrack = api.findLibManga(track)
api.findLibManga(track).flatMap { remoteTrack -> if (statusTrack != null && remoteTrack != null) {
if (remoteTrack != null && it != null) { track.copyPersonalFrom(remoteTrack)
track.copyPersonalFrom(remoteTrack) track.library_id = remoteTrack.library_id
track.library_id = remoteTrack.library_id track.status = remoteTrack.status
track.status = remoteTrack.status track.last_chapter_read = remoteTrack.last_chapter_read
track.last_chapter_read = remoteTrack.last_chapter_read refresh(track)
refresh(track) } else {
} 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() api.addLibManga(track)
track.status = DEFAULT_STATUS update(track)
add(track) }
update(track) return track
}
}
}
} }
override fun search(query: String): Observable<List<TrackSearch>> { override suspend fun search(query: String): List<TrackSearch> {
return api.search(query) return api.search(query)
} }
override fun refresh(track: Track): Observable<Track> { override suspend fun refresh(track: Track): Track {
return api.statusLibManga(track) val statusTrack = api.statusLibManga(track)
.flatMap { track.copyPersonalFrom(statusTrack!!)
track.copyPersonalFrom(it!!) val remoteTrack = api.findLibManga(track)
api.findLibManga(track) if(remoteTrack != null){
.map { remoteTrack -> track.total_chapters = remoteTrack.total_chapters
if (remoteTrack != null) { track.status = remoteTrack.status
track.total_chapters = remoteTrack.total_chapters }
track.status = remoteTrack.status return track
}
track
}
}
} }
override fun getLogo() = R.drawable.tracker_bangumi override fun getLogo() = R.drawable.tracker_bangumi
@ -99,17 +87,20 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
} }
} }
override fun login(username: String, password: String) = login(password) override suspend fun login(username: String, password: String): Boolean = login(password)
fun login(code: String): Completable { suspend fun login(code: String): Boolean {
return api.accessToken(code).map { oauth: OAuth? -> try {
val oauth = api.accessToken(code)
interceptor.newAuth(oauth) interceptor.newAuth(oauth)
if (oauth != null) { saveCredentials(oauth.user_id.toString(), oauth.access_token)
saveCredentials(oauth.user_id.toString(), oauth.access_token) return true
} } catch (e: Exception) {
}.doOnError { Timber.e(e)
logout() logout()
}.toCompletable() }
return false
} }
fun saveToken(oauth: OAuth?) { fun saveToken(oauth: OAuth?) {
@ -128,15 +119,15 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
override fun logout() { override fun logout() {
super.logout() super.logout()
preferences.trackToken(this).set(null) preferences.trackToken(this).set(null)
interceptor.newAuth(null) interceptor.clearOauth()
} }
companion object { companion object {
const val READING = 3 const val PLANNING = 1
const val COMPLETED = 2 const val COMPLETED = 2
const val READING = 3
const val ON_HOLD = 4 const val ON_HOLD = 4
const val DROPPED = 5 const val DROPPED = 5
const val PLANNING = 1
const val DEFAULT_STATUS = READING const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0 const val DEFAULT_SCORE = 0

View File

@ -10,91 +10,86 @@ import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.await
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.CacheControl import okhttp3.CacheControl
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder import java.net.URLEncoder
class BangumiApi(private val client: OkHttpClient, interceptor: BangumiInterceptor) { class BangumiApi(private val client: OkHttpClient, interceptor: BangumiInterceptor) {
private val gson: Gson by injectLazy() private val gson: Gson by injectLazy()
private val parser = JsonParser()
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track): Observable<Track> { suspend fun addLibManga(track: Track): Track {
val body = FormBody.Builder() val body = FormBody.Builder()
.add("rating", track.score.toInt().toString()) .add("rating", track.score.toInt().toString())
.add("status", track.toBangumiStatus()) .add("status", track.toBangumiStatus())
.build() .build()
val request = Request.Builder() val request = Request.Builder()
.url("$apiUrl/collection/${track.media_id}/update") .url("$apiUrl/collection/${track.media_id}/update")
.post(body) .post(body)
.build() .build()
return authClient.newCall(request) val response = authClient.newCall(request).await()
.asObservableSuccess() return track
.map {
track
}
} }
fun updateLibManga(track: Track): Observable<Track> { suspend fun updateLibManga(track: Track): Track {
// chapter update // chapter update
val body = FormBody.Builder() return withContext(Dispatchers.IO) {
val body = FormBody.Builder()
.add("watched_eps", track.last_chapter_read.toString()) .add("watched_eps", track.last_chapter_read.toString())
.build() .build()
val request = Request.Builder() val request = Request.Builder()
.url("$apiUrl/subject/${track.media_id}/update/watched_eps") .url("$apiUrl/subject/${track.media_id}/update/watched_eps")
.post(body) .post(body)
.build() .build()
// read status update // read status update
val sbody = FormBody.Builder() val sbody = FormBody.Builder()
.add("status", track.toBangumiStatus()) .add("status", track.toBangumiStatus())
.build() .build()
val srequest = Request.Builder() val srequest = Request.Builder()
.url("$apiUrl/collection/${track.media_id}/update") .url("$apiUrl/collection/${track.media_id}/update")
.post(sbody) .post(sbody)
.build() .build()
return authClient.newCall(srequest) authClient.newCall(srequest).execute()
.asObservableSuccess() authClient.newCall(request).execute()
.map { track
track }
}.flatMap {
authClient.newCall(request)
.asObservableSuccess()
.map {
track
}
}
} }
fun search(search: String): Observable<List<TrackSearch>> { suspend fun search(search: String): List<TrackSearch> {
val url = Uri.parse( return withContext(Dispatchers.IO) {
"$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}").buildUpon() val url = Uri.parse(
"$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}"
).buildUpon()
.appendQueryParameter("max_results", "20") .appendQueryParameter("max_results", "20")
.build() .build()
val request = Request.Builder() val request = Request.Builder()
.url(url.toString()) .url(url.toString())
.get() .get()
.build() .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) }
}
val netResponse = authClient.newCall(request).await()
var responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
if (responseBody.contains("\"code\":404")) {
responseBody = "{\"results\":0,\"list\":[]}"
}
val response = JsonParser.parseString(responseBody).obj["list"]?.array
if (response != null) {
response.filter { it.obj["type"].asInt == 1 }?.map { jsonToSearch(it.obj) }
} else {
listOf()
}
}
} }
private fun jsonToSearch(obj: JsonObject): TrackSearch { private fun jsonToSearch(obj: JsonObject): TrackSearch {
@ -119,60 +114,56 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
} }
} }
fun findLibManga(track: Track): Observable<Track?> { suspend fun findLibManga(track: Track): Track? {
val urlMangas = "$apiUrl/subject/${track.media_id}" return withContext(Dispatchers.IO) {
val requestMangas = Request.Builder() val urlMangas = "$apiUrl/subject/${track.media_id}"
val requestMangas = Request.Builder()
.url(urlMangas) .url(urlMangas)
.get() .get()
.build() .build()
val netResponse = authClient.newCall(requestMangas).execute()
return authClient.newCall(requestMangas) val responseBody = netResponse.body?.string().orEmpty()
.asObservableSuccess() jsonToTrack(JsonParser.parseString(responseBody).obj)
.map { netResponse -> }
// get comic info
val responseBody = netResponse.body?.string().orEmpty()
jsonToTrack(parser.parse(responseBody).obj)
}
} }
fun statusLibManga(track: Track): Observable<Track?> { suspend fun statusLibManga(track: Track): Track? {
val urlUserRead = "$apiUrl/collection/${track.media_id}" val urlUserRead = "$apiUrl/collection/${track.media_id}"
val requestUserRead = Request.Builder() val requestUserRead = Request.Builder()
.url(urlUserRead) .url(urlUserRead)
.cacheControl(CacheControl.FORCE_NETWORK) .cacheControl(CacheControl.FORCE_NETWORK)
.get() .get()
.build() .build()
// todo get user readed chapter here // todo get user readed chapter here
return authClient.newCall(requestUserRead) val response = authClient.newCall(requestUserRead).await()
.asObservableSuccess() val resp = response.body?.toString()
.map { netResponse -> val coll = gson.fromJson(resp, Collection::class.java)
val resp = netResponse.body?.string() track.status = coll.status?.id!!
val coll = gson.fromJson(resp, Collection::class.java) track.last_chapter_read = coll.ep_status!!
track.status = coll.status?.id!! return track
track.last_chapter_read = coll.ep_status!!
track
}
} }
fun accessToken(code: String): Observable<OAuth> { suspend fun accessToken(code: String): OAuth {
return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse -> return withContext(Dispatchers.IO){
val netResponse = client.newCall(accessTokenRequest(code)).execute()
val responseBody = netResponse.body?.string().orEmpty() val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) { if(responseBody.isEmpty()){
throw Exception("Null Response") throw Exception("Null Response")
} }
gson.fromJson(responseBody, OAuth::class.java) gson.fromJson(responseBody, OAuth::class.java)
} }
} }
private fun accessTokenRequest(code: String) = POST(oauthUrl, private fun accessTokenRequest(code: String) = POST(
body = FormBody.Builder() oauthUrl,
.add("grant_type", "authorization_code") body = FormBody.Builder()
.add("client_id", clientId) .add("grant_type", "authorization_code")
.add("client_secret", clientSecret) .add("client_id", clientId)
.add("code", code) .add("client_secret", clientSecret)
.add("redirect_uri", redirectUrl) .add("code", code)
.build() .add("redirect_uri", redirectUrl)
.build()
) )
companion object { companion object {
@ -192,20 +183,21 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
} }
fun authUrl() = fun authUrl() =
Uri.parse(loginUrl).buildUpon() Uri.parse(loginUrl).buildUpon()
.appendQueryParameter("client_id", clientId) .appendQueryParameter("client_id", clientId)
.appendQueryParameter("response_type", "code") .appendQueryParameter("response_type", "code")
.appendQueryParameter("redirect_uri", redirectUrl) .appendQueryParameter("redirect_uri", redirectUrl)
.build() .build()
fun refreshTokenRequest(token: String) = POST(oauthUrl, fun refreshTokenRequest(token: String) = POST(
body = FormBody.Builder() oauthUrl,
.add("grant_type", "refresh_token") body = FormBody.Builder()
.add("client_id", clientId) .add("grant_type", "refresh_token")
.add("client_secret", clientSecret) .add("client_id", clientId)
.add("refresh_token", token) .add("client_secret", clientSecret)
.add("redirect_uri", redirectUrl) .add("refresh_token", token)
.build()) .add("redirect_uri", redirectUrl)
.build()
)
} }
} }

View File

@ -47,8 +47,8 @@ class BangumiInterceptor(val bangumi: Bangumi, val gson: Gson) : Interceptor {
return chain.proceed(authRequest) return chain.proceed(authRequest)
} }
fun newAuth(oauth: OAuth?) { fun newAuth(oauth: OAuth) {
this.oauth = if (oauth == null) null else OAuth( this.oauth = OAuth(
oauth.access_token, oauth.access_token,
oauth.token_type, oauth.token_type,
System.currentTimeMillis() / 1000, System.currentTimeMillis() / 1000,
@ -58,4 +58,8 @@ class BangumiInterceptor(val bangumi: Bangumi, val gson: Gson) : Interceptor {
bangumi.saveToken(oauth) bangumi.saveToken(oauth)
} }
fun clearOauth(){
bangumi.saveToken(null)
}
} }

View File

@ -11,3 +11,39 @@ data class Collection(
val user: User? = User(), val user: User? = User(),
val vol_status: Int? = 0 val vol_status: Int? = 0
) )
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 refresh before expired
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}
data class Status(
val id: Int? = 0,
val name: String? = "",
val type: String? = ""
)
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? = ""
)
data class Avatar(
val large: String? = "",
val medium: String? = "",
val small: String? = ""
)

View File

@ -1,16 +0,0 @@
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 refresh before expired
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}

View File

@ -1,7 +0,0 @@
package eu.kanade.tachiyomi.data.track.bangumi
data class Status(
val id: Int? = 0,
val name: String? = "",
val type: String? = ""
)

View File

@ -1,11 +0,0 @@
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? = ""
)

View File

@ -7,8 +7,7 @@ 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 timber.log.Timber
import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.DecimalFormat import java.text.DecimalFormat
@ -70,11 +69,7 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
return df.format(track.score) return df.format(track.score)
} }
override fun add(track: Track): Observable<Track> { override suspend fun update(track: Track): Track {
return api.addLibManga(track, getUserId())
}
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
} }
@ -82,41 +77,41 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
return api.updateLibManga(track) return api.updateLibManga(track)
} }
override fun bind(track: Track): Observable<Track> { override suspend fun bind(track: Track): Track {
return api.findLibManga(track, getUserId()) val remoteTrack = api.findLibManga(track, getUserId())
.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 return update(track)
update(track) } else {
} else { track.score = DEFAULT_SCORE
track.score = DEFAULT_SCORE track.status = DEFAULT_STATUS
track.status = DEFAULT_STATUS return api.addLibManga(track, getUserId())
add(track) }
}
}
} }
override fun search(query: String): Observable<List<TrackSearch>> { override suspend fun search(query: String): List<TrackSearch> {
return api.search(query) return api.search(query)
} }
override fun refresh(track: Track): Observable<Track> { override suspend fun refresh(track: Track): Track {
return api.getLibManga(track) val remoteTrack = api.getLibManga(track)
.map { remoteTrack -> track.copyPersonalFrom(remoteTrack)
track.copyPersonalFrom(remoteTrack) track.total_chapters = remoteTrack.total_chapters
track.total_chapters = remoteTrack.total_chapters return track
track
}
} }
override fun login(username: String, password: String): Completable { override suspend fun login(username: String, password: String): Boolean {
return api.login(username, password) try {
.doOnNext { interceptor.newAuth(it) } val oauth = api.login(username, password)
.flatMap { api.getCurrentUser() } interceptor.newAuth(oauth)
.doOnNext { userId -> saveCredentials(username, userId) } val userId = api.getCurrentUser()
.doOnError { logout() } saveCredentials(username, userId)
.toCompletable() return true
} catch (e: Exception) {
Timber.e(e)
return false
}
} }
override fun logout() { override fun logout() {
@ -140,5 +135,4 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
null null
} }
} }
} }

View File

@ -1,6 +1,11 @@
package eu.kanade.tachiyomi.data.track.kitsu package eu.kanade.tachiyomi.data.track.kitsu
import com.github.salomonbrys.kotson.* import com.github.salomonbrys.kotson.array
import com.github.salomonbrys.kotson.get
import com.github.salomonbrys.kotson.int
import com.github.salomonbrys.kotson.jsonObject
import com.github.salomonbrys.kotson.obj
import com.github.salomonbrys.kotson.string
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.JsonObject import com.google.gson.JsonObject
import eu.kanade.tachiyomi.data.database.models.Track import eu.kanade.tachiyomi.data.database.models.Track
@ -9,240 +14,228 @@ import eu.kanade.tachiyomi.network.POST
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.* import retrofit2.http.Body
import rx.Observable import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Headers
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) { class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) {
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
private val rest = Retrofit.Builder() private val rest = Retrofit.Builder()
.baseUrl(baseUrl) .baseUrl(baseUrl)
.client(authClient) .client(authClient)
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create())) .addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
.addCallAdapterFactory(RxJavaCallAdapterFactory.create()) .build()
.build() .create(KitsuApi.Rest::class.java)
.create(KitsuApi.Rest::class.java)
private val searchRest = Retrofit.Builder() private val searchRest = Retrofit.Builder()
.baseUrl(algoliaKeyUrl) .baseUrl(algoliaKeyUrl)
.client(authClient) .client(authClient)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create()) .build()
.build() .create(KitsuApi.SearchKeyRest::class.java)
.create(KitsuApi.SearchKeyRest::class.java)
private val algoliaRest = Retrofit.Builder() private val algoliaRest = Retrofit.Builder()
.baseUrl(algoliaUrl) .baseUrl(algoliaUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(KitsuApi.AgoliaSearchRest::class.java)
suspend fun addLibManga(track: Track, userId: String): Track {
// @formatter:off
val data = jsonObject(
"type" to "libraryEntries",
"attributes" to jsonObject(
"status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read
),
"relationships" to jsonObject(
"user" to jsonObject(
"data" to jsonObject(
"id" to userId,
"type" to "users"
)
),
"media" to jsonObject(
"data" to jsonObject(
"id" to track.media_id,
"type" to "manga"
)
)
)
)
val json = rest.addLibManga(jsonObject("data" to data))
track.media_id = json["data"]["id"].int
return track
}
suspend fun updateLibManga(track: Track): Track {
// @formatter:off
val data = jsonObject(
"type" to "libraryEntries",
"id" to track.media_id,
"attributes" to jsonObject(
"status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read,
"ratingTwenty" to track.toKitsuScore()
)
)
// @formatter:on
rest.updateLibManga(track.media_id, jsonObject("data" to data))
return track
}
suspend fun search(query: String): List<TrackSearch> {
val key = searchRest.getKey()["media"].asJsonObject["key"].string
return algoliaSearch(key, query)
}
private suspend fun algoliaSearch(key: String, query: String): List<TrackSearch> {
val jsonObject = jsonObject("params" to "query=$query$algoliaFilter")
val json = algoliaRest.getSearchQuery(algoliaAppId, key, jsonObject)
val data = json["hits"].array
return data.map { KitsuSearchManga(it.obj) }
.filter { it.subType != "novel" }
.map { it.toTrack() }
}
suspend fun findLibManga(track: Track, userId: String): Track? {
val json = rest.findLibManga(track.media_id, userId)
val data = json["data"].array
return if (data.size() > 0) {
val manga = json["included"].array[0].obj
KitsuLibManga(data[0].obj, manga).toTrack()
} else {
null
}
}
suspend fun getLibManga(track: Track): Track {
val json = rest.getLibManga(track.media_id)
val data = json["data"].array
if (data.size() > 0) {
val manga = json["included"].array[0].obj
return KitsuLibManga(data[0].obj, manga).toTrack()
} else {
throw Exception("Could not find manga")
}
}
suspend fun login(username: String, password: String): OAuth {
return Retrofit.Builder()
.baseUrl(loginUrl)
.client(client) .client(client)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build() .build()
.create(KitsuApi.AgoliaSearchRest::class.java) .create(KitsuApi.LoginRest::class.java)
.requestAccessToken(username, password)
fun addLibManga(track: Track, userId: String): Observable<Track> {
return Observable.defer {
// @formatter:off
val data = jsonObject(
"type" to "libraryEntries",
"attributes" to jsonObject(
"status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read
),
"relationships" to jsonObject(
"user" to jsonObject(
"data" to jsonObject(
"id" to userId,
"type" to "users"
)
),
"media" to jsonObject(
"data" to jsonObject(
"id" to track.media_id,
"type" to "manga"
)
)
)
)
rest.addLibManga(jsonObject("data" to data))
.map { json ->
track.media_id = json["data"]["id"].int
track
}
}
} }
fun updateLibManga(track: Track): Observable<Track> { suspend fun getCurrentUser(): String {
return Observable.defer { val currentUser = rest.getCurrentUser()
// @formatter:off return currentUser["data"].array[0]["id"].string
val data = jsonObject(
"type" to "libraryEntries",
"id" to track.media_id,
"attributes" to jsonObject(
"status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read,
"ratingTwenty" to track.toKitsuScore()
)
)
// @formatter:on
rest.updateLibManga(track.media_id, jsonObject("data" to data))
.map { track }
}
}
fun search(query: String): Observable<List<TrackSearch>> {
return searchRest
.getKey().map { json ->
json["media"].asJsonObject["key"].string
}.flatMap { key ->
algoliaSearch(key, query)
}
}
private fun algoliaSearch(key: String, query: String): Observable<List<TrackSearch>> {
val jsonObject = jsonObject("params" to "query=$query$algoliaFilter")
return algoliaRest
.getSearchQuery(algoliaAppId, key, jsonObject)
.map { json ->
val data = json["hits"].array
data.map { KitsuSearchManga(it.obj) }
.filter { it.subType != "novel" }
.map { it.toTrack() }
}
}
fun findLibManga(track: Track, userId: String): Observable<Track?> {
return rest.findLibManga(track.media_id, userId)
.map { json ->
val data = json["data"].array
if (data.size() > 0) {
val manga = json["included"].array[0].obj
KitsuLibManga(data[0].obj, manga).toTrack()
} else {
null
}
}
}
fun getLibManga(track: Track): Observable<Track> {
return rest.getLibManga(track.media_id)
.map { json ->
val data = json["data"].array
if (data.size() > 0) {
val manga = json["included"].array[0].obj
KitsuLibManga(data[0].obj, manga).toTrack()
} else {
throw Exception("Could not find manga")
}
}
}
fun login(username: String, password: String): Observable<OAuth> {
return Retrofit.Builder()
.baseUrl(loginUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(KitsuApi.LoginRest::class.java)
.requestAccessToken(username, password)
}
fun getCurrentUser(): Observable<String> {
return rest.getCurrentUser().map { it["data"].array[0]["id"].string }
} }
private interface Rest { private interface Rest {
@Headers("Content-Type: application/vnd.api+json") @Headers("Content-Type: application/vnd.api+json")
@POST("library-entries") @POST("library-entries")
fun addLibManga( suspend fun addLibManga(
@Body data: JsonObject @Body data: JsonObject
): Observable<JsonObject> ): JsonObject
@Headers("Content-Type: application/vnd.api+json") @Headers("Content-Type: application/vnd.api+json")
@PATCH("library-entries/{id}") @PATCH("library-entries/{id}")
fun updateLibManga( suspend fun updateLibManga(
@Path("id") remoteId: Int, @Path("id") remoteId: Int,
@Body data: JsonObject @Body data: JsonObject
): Observable<JsonObject> ): JsonObject
@GET("library-entries") @GET("library-entries")
fun findLibManga( suspend fun findLibManga(
@Query("filter[manga_id]", encoded = true) remoteId: Int, @Query("filter[manga_id]", encoded = true) remoteId: Int,
@Query("filter[user_id]", encoded = true) userId: String, @Query("filter[user_id]", encoded = true) userId: String,
@Query("include") includes: String = "manga" @Query("include") includes: String = "manga"
): Observable<JsonObject> ): JsonObject
@GET("library-entries") @GET("library-entries")
fun getLibManga( suspend fun getLibManga(
@Query("filter[id]", encoded = true) remoteId: Int, @Query("filter[id]", encoded = true) remoteId: Int,
@Query("include") includes: String = "manga" @Query("include") includes: String = "manga"
): Observable<JsonObject> ): JsonObject
@GET("users") @GET("users")
fun getCurrentUser( suspend fun getCurrentUser(
@Query("filter[self]", encoded = true) self: Boolean = true @Query("filter[self]", encoded = true) self: Boolean = true
): Observable<JsonObject> ): JsonObject
} }
private interface SearchKeyRest { private interface SearchKeyRest {
@GET("media/") @GET("media/")
fun getKey(): Observable<JsonObject> suspend fun getKey(): JsonObject
} }
private interface AgoliaSearchRest { private interface AgoliaSearchRest {
@POST("query/") @POST("query/")
fun getSearchQuery(@Header("X-Algolia-Application-Id") appid: String, @Header("X-Algolia-API-Key") key: String, @Body json: JsonObject): Observable<JsonObject> suspend fun getSearchQuery(
@Header("X-Algolia-Application-Id") appid: String,
@Header("X-Algolia-API-Key") key: String,
@Body json: JsonObject
): JsonObject
} }
private interface LoginRest { private interface LoginRest {
@FormUrlEncoded @FormUrlEncoded
@POST("oauth/token") @POST("oauth/token")
fun requestAccessToken( suspend fun requestAccessToken(
@Field("username") username: String, @Field("username") username: String,
@Field("password") password: String, @Field("password") password: String,
@Field("grant_type") grantType: String = "password", @Field("grant_type") grantType: String = "password",
@Field("client_id") client_id: String = clientId, @Field("client_id") client_id: String = clientId,
@Field("client_secret") client_secret: String = clientSecret @Field("client_secret") client_secret: String = clientSecret
): Observable<OAuth> ): OAuth
} }
companion object { companion object {
private const val clientId = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd" private const val clientId =
private const val clientSecret = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151" "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
private const val clientSecret =
"54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
private const val baseUrl = "https://kitsu.io/api/edge/" private const val baseUrl = "https://kitsu.io/api/edge/"
private const val loginUrl = "https://kitsu.io/api/" private const val loginUrl = "https://kitsu.io/api/"
private const val baseMangaUrl = "https://kitsu.io/manga/" private const val baseMangaUrl = "https://kitsu.io/manga/"
private const val algoliaKeyUrl = "https://kitsu.io/api/edge/algolia-keys/" private const val algoliaKeyUrl = "https://kitsu.io/api/edge/algolia-keys/"
private const val algoliaUrl = "https://AWQO5J657S-dsn.algolia.net/1/indexes/production_media/" private const val algoliaUrl =
"https://AWQO5J657S-dsn.algolia.net/1/indexes/production_media/"
private const val algoliaAppId = "AWQO5J657S" private const val algoliaAppId = "AWQO5J657S"
private const val algoliaFilter = "&facetFilters=%5B%22kind%3Amanga%22%5D&attributesToRetrieve=%5B%22synopsis%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D" private const val algoliaFilter =
"&facetFilters=%5B%22kind%3Amanga%22%5D&attributesToRetrieve=%5B%22synopsis%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D"
fun mangaUrl(remoteId: Int): String { fun mangaUrl(remoteId: Int): String {
return baseMangaUrl + remoteId return baseMangaUrl + remoteId
} }
fun refreshTokenRequest(token: String) = POST(
fun refreshTokenRequest(token: String) = POST("${loginUrl}oauth/token", "${loginUrl}oauth/token",
body = FormBody.Builder() body = FormBody.Builder()
.add("grant_type", "refresh_token") .add("grant_type", "refresh_token")
.add("client_id", clientId) .add("client_id", clientId)
.add("client_secret", clientSecret) .add("client_secret", clientSecret)
.add("refresh_token", token) .add("refresh_token", token)
.build()) .build()
)
} }
} }

View File

@ -7,33 +7,15 @@ 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 okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import rx.Completable import timber.log.Timber
import rx.Observable
class Myanimelist(private val context: Context, id: Int) : TrackService(id) { class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLAN_TO_READ = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
const val BASE_URL = "https://myanimelist.net"
const val USER_SESSION_COOKIE = "MALSESSIONID"
const val LOGGED_IN_COOKIE = "is_logged_in"
}
private val interceptor by lazy { MyAnimeListInterceptor(this) } private val interceptor by lazy { MyAnimeListInterceptor(this) }
private val api by lazy { MyAnimeListApi(client, interceptor) } private val api by lazy { MyAnimeListApi(client, interceptor) }
override val name: String override val name = "MyAnimeList"
get() = "MyAnimeList"
override fun getLogo() = R.drawable.tracker_mal override fun getLogo() = R.drawable.tracker_mal
@ -62,11 +44,7 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
return track.score.toInt().toString() return track.score.toInt().toString()
} }
override fun add(track: Track): Observable<Track> { override suspend fun update(track: Track): Track {
return api.addLibManga(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
} }
@ -74,45 +52,46 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
return api.updateLibManga(track) return api.updateLibManga(track)
} }
override fun bind(track: Track): Observable<Track> { override suspend fun bind(track: Track): Track {
return api.findLibManga(track) val remoteTrack = api.findLibManga(track)
.flatMap { remoteTrack -> if (remoteTrack != null) {
if (remoteTrack != null) { track.copyPersonalFrom(remoteTrack)
track.copyPersonalFrom(remoteTrack) 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 return api.addLibManga(track)
add(track) }
} return track
}
} }
override fun search(query: String): Observable<List<TrackSearch>> { override suspend fun search(query: String): List<TrackSearch> {
return api.search(query) return api.search(query)
} }
override fun refresh(track: Track): Observable<Track> { override suspend fun refresh(track: Track): Track {
return api.getLibManga(track) val remoteTrack = api.getLibManga(track)
.map { remoteTrack -> track.copyPersonalFrom(remoteTrack)
track.copyPersonalFrom(remoteTrack) track.total_chapters = remoteTrack.total_chapters
track.total_chapters = remoteTrack.total_chapters return track
track
}
} }
override fun login(username: String, password: String): Completable { override suspend fun login(username: String, password: String): Boolean {
logout() logout()
return try {
return Observable.fromCallable { api.login(username, password) } val csrf = api.login(username, password)
.doOnNext { csrf -> saveCSRF(csrf) } saveCSRF(csrf)
.doOnNext { saveCredentials(username, password) } saveCredentials(username, password)
.doOnError { logout() } true
.toCompletable() } catch (e: Exception) {
Timber.e(e)
logout()
false
}
} }
fun refreshLogin() { private suspend fun refreshLogin() {
val username = getUsername() val username = getUsername()
val password = getPassword() val password = getPassword()
logout() logout()
@ -122,13 +101,14 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
saveCSRF(csrf) saveCSRF(csrf)
saveCredentials(username, password) saveCredentials(username, password)
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e)
logout() logout()
throw e throw e
} }
} }
// Attempt to login again if cookies have been cleared but credentials are still filled // Attempt to login again if cookies have been cleared but credentials are still filled
fun ensureLoggedIn() { suspend fun ensureLoggedIn() {
if (isAuthorized) return if (isAuthorized) return
if (!isLogged) throw Exception("MAL Login Credentials not found") if (!isLogged) throw Exception("MAL Login Credentials not found")
@ -141,10 +121,7 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
networkService.cookieManager.remove(BASE_URL.toHttpUrlOrNull()!!) networkService.cookieManager.remove(BASE_URL.toHttpUrlOrNull()!!)
} }
val isAuthorized: Boolean private val isAuthorized = super.isLogged && getCSRF().isNotEmpty() && checkCookies()
get() = super.isLogged &&
getCSRF().isNotEmpty() &&
checkCookies()
fun getCSRF(): String = preferences.trackToken(this).getOrDefault() fun getCSRF(): String = preferences.trackToken(this).getOrDefault()
@ -161,4 +138,18 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
return ckCount == 2 return ckCount == 2
} }
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLAN_TO_READ = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
const val BASE_URL = "https://myanimelist.net"
const val USER_SESSION_COOKIE = "MALSESSIONID"
const val LOGGED_IN_COOKIE = "is_logged_in"
}
} }

View File

@ -6,50 +6,41 @@ import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservable import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.consumeBody
import eu.kanade.tachiyomi.network.consumeXmlBody
import eu.kanade.tachiyomi.util.selectInt import eu.kanade.tachiyomi.util.selectInt
import eu.kanade.tachiyomi.util.selectText import eu.kanade.tachiyomi.util.selectText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.json.JSONObject import org.json.JSONObject
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import org.jsoup.parser.Parser import org.jsoup.parser.Parser
import rx.Observable
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.zip.GZIPInputStream
class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) { class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListInterceptor) {
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun search(query: String): Observable<List<TrackSearch>> { suspend fun search(query: String): List<TrackSearch> {
return if (query.startsWith(PREFIX_MY)) { return withContext(Dispatchers.IO) {
val realQuery = query.removePrefix(PREFIX_MY) if (query.startsWith(PREFIX_MY)) {
getList() queryUsersList(query)
.flatMap { Observable.from(it) } } else {
.filter { it.title.contains(realQuery, true) } val realQuery = query.take(100)
.toList() val response = client.newCall(GET(searchUrl(realQuery))).await()
} else { val matches = Jsoup.parse(response.consumeBody())
client.newCall(GET(searchUrl(query))) .select("div.js-categories-seasonal.js-block-list.list")
.asObservable() .select("table").select("tbody")
.flatMap { response -> .select("tr").drop(1)
Observable.from(Jsoup.parse(response.consumeBody())
.select("div.js-categories-seasonal.js-block-list.list") matches.filter { row -> row.select(TD)[2].text() != "Novel" }
.select("table").select("tbody")
.select("tr").drop(1))
}
.filter { row ->
row.select(TD)[2].text() != "Novel"
}
.map { row -> .map { row ->
TrackSearch.create(TrackManager.MYANIMELIST).apply { TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = row.searchTitle() title = row.searchTitle()
@ -64,136 +55,119 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
} }
} }
.toList() .toList()
}
} }
} }
fun addLibManga(track: Track): Observable<Track> { private suspend fun queryUsersList(query: String): List<TrackSearch> {
return Observable.defer { val realQuery = query.removePrefix(PREFIX_MY).take(100)
authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track))) return getList().filter { it.title.contains(realQuery, true) }.toList()
.asObservableSuccess()
.map { track }
}
} }
fun updateLibManga(track: Track): Observable<Track> { suspend fun addLibManga(track: Track): Track {
return Observable.defer { authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track))).await()
authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track))) return track
.asObservableSuccess()
.map { track }
}
} }
fun findLibManga(track: Track): Observable<Track?> { suspend fun updateLibManga(track: Track): Track {
return authClient.newCall(GET(url = listEntryUrl(track.media_id))) authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track))).await()
.asObservable() return track
.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 { suspend fun findLibManga(track: Track): Track? {
last_chapter_read = trackForm.select("#add_manga_num_read_chapters").`val`().toInt() return withContext(Dispatchers.IO) {
total_chapters = trackForm.select("#totalChap").text().toInt() val response = authClient.newCall(GET(url = listEntryUrl(track.media_id))).await()
status = trackForm.select("#add_manga_status > option[selected]").`val`().toInt() var remoteTrack: Track? = null
score = trackForm.select("#add_manga_score > option[selected]").`val`().toFloatOrNull() ?: 0f response.use {
} if (it.priorResponse?.isRedirect != true) {
} val trackForm = Jsoup.parse(it.consumeBody())
remoteTrack = 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
} }
}
remoteTrack
}
} }
fun getLibManga(track: Track): Observable<Track> { suspend fun getLibManga(track: Track): Track {
return findLibManga(track) val result = findLibManga(track)
.map { it ?: throw Exception("Could not find manga") } if (result == null) {
throw Exception("Could not find manga")
} else {
return result
}
} }
fun login(username: String, password: String): String { suspend fun login(username: String, password: String): String {
val csrf = getSessionInfo() return withContext(Dispatchers.IO) {
val csrf = getSessionInfo()
login(username, password, csrf) login(username, password, csrf)
csrf
return csrf }
} }
private fun getSessionInfo(): String { private suspend fun getSessionInfo(): String {
val response = client.newCall(GET(loginUrl())).execute() val response = client.newCall(GET(loginUrl())).execute()
return Jsoup.parse(response.consumeBody()) return Jsoup.parse(response.consumeBody())
.select("meta[name=csrf_token]") .select("meta[name=csrf_token]")
.attr("content") .attr("content")
} }
private fun login(username: String, password: String, csrf: String) { private suspend fun login(username: String, password: String, csrf: String) {
val response = client.newCall(POST(url = loginUrl(), body = loginPostBody(username, password, csrf))).execute() withContext(Dispatchers.IO) {
val response =
client.newCall(POST(loginUrl(), body = loginPostBody(username, password, csrf)))
.execute()
response.use { response.use {
if (response.priorResponse?.code != 302) throw Exception("Authentication error") if (response.priorResponse?.code != 302) throw Exception("Authentication error")
}
}
private fun getList(): Observable<List<TrackSearch>> {
return getListUrl()
.flatMap { url ->
getListXml(url)
}
.flatMap { doc ->
Observable.from(doc.select("manga"))
}
.map {
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("manga_title")!!
media_id = it.selectInt("manga_mangadb_id")
last_chapter_read = it.selectInt("my_read_chapters")
status = getStatus(it.selectText("my_status")!!)
score = it.selectInt("my_score").toFloat()
total_chapters = it.selectInt("manga_chapters")
tracking_url = mangaUrl(media_id)
}
}
.toList()
}
private fun getListUrl(): Observable<String> {
return authClient.newCall(POST(url = exportListUrl(), body = exportPostBody()))
.asObservable()
.map {response ->
baseUrl + Jsoup.parse(response.consumeBody())
.select("div.goodresult")
.select("a")
.attr("href")
}
}
private fun getListXml(url: String): Observable<Document> {
return authClient.newCall(GET(url))
.asObservable()
.map { response ->
Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
}
}
private fun Response.consumeBody(): String? {
use {
if (it.code != 200) throw Exception("HTTP error ${it.code}")
return it.body?.string()
}
}
private fun Response.consumeXmlBody(): String? {
use { res ->
if (res.code != 200) throw Exception("Export list error")
BufferedReader(InputStreamReader(GZIPInputStream(res.body?.source()?.inputStream()))).use { reader ->
val sb = StringBuilder()
reader.forEachLine { line ->
sb.append(line)
}
return sb.toString()
} }
} }
} }
private suspend fun getList(): List<TrackSearch> {
val results = getListXml(getListUrl()).select("manga")
return results.map {
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("manga_title")!!
media_id = it.selectInt("manga_mangadb_id")
last_chapter_read = it.selectInt("my_read_chapters")
status = getStatus(it.selectText("my_status")!!)
score = it.selectInt("my_score").toFloat()
total_chapters = it.selectInt("manga_chapters")
tracking_url = mangaUrl(media_id)
}
}
.toList()
}
private suspend fun getListUrl(): String {
return withContext(Dispatchers.IO) {
val response =
authClient.newCall(POST(url = exportListUrl(), body = exportPostBody())).execute()
baseUrl + Jsoup.parse(response.consumeBody())
.select("div.goodresult")
.select("a")
.attr("href")
}
}
private suspend fun getListXml(url: String): Document {
val response = authClient.newCall(GET(url)).await()
return Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
}
companion object { companion object {
const val CSRF = "csrf_token" const val CSRF = "csrf_token"
@ -206,88 +180,91 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
private fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId private fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
private fun loginUrl() = Uri.parse(baseUrl).buildUpon() private fun loginUrl() = Uri.parse(baseUrl).buildUpon()
.appendPath("login.php") .appendPath("login.php")
.toString() .toString()
private fun searchUrl(query: String): String { private fun searchUrl(query: String): String {
val col = "c[]" val col = "c[]"
return Uri.parse(baseUrl).buildUpon() return Uri.parse(baseUrl).buildUpon()
.appendPath("manga.php") .appendPath("manga.php")
.appendQueryParameter("q", query) .appendQueryParameter("q", query)
.appendQueryParameter(col, "a") .appendQueryParameter(col, "a")
.appendQueryParameter(col, "b") .appendQueryParameter(col, "b")
.appendQueryParameter(col, "c") .appendQueryParameter(col, "c")
.appendQueryParameter(col, "d") .appendQueryParameter(col, "d")
.appendQueryParameter(col, "e") .appendQueryParameter(col, "e")
.appendQueryParameter(col, "g") .appendQueryParameter(col, "g")
.toString() .toString()
} }
private fun exportListUrl() = Uri.parse(baseUrl).buildUpon() private fun exportListUrl() = Uri.parse(baseUrl).buildUpon()
.appendPath("panel.php") .appendPath("panel.php")
.appendQueryParameter("go", "export") .appendQueryParameter("go", "export")
.toString() .toString()
private fun updateUrl() = Uri.parse(baseModifyListUrl).buildUpon() private fun updateUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath("edit.json") .appendPath("edit.json")
.toString() .toString()
private fun addUrl() = Uri.parse(baseModifyListUrl).buildUpon() private fun addUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath( "add.json") .appendPath("add.json")
.toString() .toString()
private fun listEntryUrl(mediaId: Int) = Uri.parse(baseModifyListUrl).buildUpon() private fun listEntryUrl(mediaId: Int) = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath(mediaId.toString()) .appendPath(mediaId.toString())
.appendPath("edit") .appendPath("edit")
.toString() .toString()
private fun loginPostBody(username: String, password: String, csrf: String): RequestBody { private fun loginPostBody(username: String, password: String, csrf: String): RequestBody {
return FormBody.Builder() return FormBody.Builder()
.add("user_name", username) .add("user_name", username)
.add("password", password) .add("password", password)
.add("cookie", "1") .add("cookie", "1")
.add("sublogin", "Login") .add("sublogin", "Login")
.add("submit", "1") .add("submit", "1")
.add(CSRF, csrf) .add(CSRF, csrf)
.build() .build()
} }
private fun exportPostBody(): RequestBody { private fun exportPostBody(): RequestBody {
return FormBody.Builder() return FormBody.Builder()
.add("type", "2") .add("type", "2")
.add("subexport", "Export My List") .add("subexport", "Export My List")
.build() .build()
} }
private fun mangaPostPayload(track: Track): RequestBody { private fun mangaPostPayload(track: Track): RequestBody {
val body = JSONObject() val body = JSONObject()
.put("manga_id", track.media_id) .put("manga_id", track.media_id)
.put("status", track.status) .put("status", track.status)
.put("score", track.score) .put("score", track.score)
.put("num_read_chapters", track.last_chapter_read) .put("num_read_chapters", track.last_chapter_read)
return body.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull()) return body.toString()
.toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())
} }
private fun Element.searchTitle() = select("strong").text()!! 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.searchTotalChapters() =
if (select(TD)[4].text() == "-") 0 else select(TD)[4].text().toInt()
private fun Element.searchCoverUrl() = select("img") private fun Element.searchCoverUrl() = select("img")
.attr("data-src") .attr("data-src")
.split("\\?")[0] .split("\\?")[0]
.replace("/r/50x70/", "/") .replace("/r/50x70/", "/")
private 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()
private fun Element.searchSummary() = select("div.pt4") private fun Element.searchSummary() = select("div.pt4")
.first() .first()
.ownText()!! .ownText()!!
private fun Element.searchPublishingStatus() = if (select(TD).last().text() == "-") "Publishing" else "Finished" private fun Element.searchPublishingStatus() =
if (select(TD).last().text() == "-") "Publishing" else "Finished"
private fun Element.searchPublishingType() = select(TD)[2].text()!! private fun Element.searchPublishingType() = select(TD)[2].text()!!
@ -300,6 +277,6 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
"Dropped" -> 4 "Dropped" -> 4
"Plan to Read" -> 6 "Plan to Read" -> 6
else -> 1 else -> 1
} }
} }
} }

View File

@ -1,5 +1,9 @@
package eu.kanade.tachiyomi.data.track.myanimelist package eu.kanade.tachiyomi.data.track.myanimelist
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
@ -8,20 +12,17 @@ import okhttp3.Response
import okio.Buffer import okio.Buffer
import org.json.JSONObject import org.json.JSONObject
class MyAnimeListInterceptor(private val myanimelist: Myanimelist): Interceptor { class MyAnimeListInterceptor(private val myanimelist: MyAnimeList) : Interceptor {
val scope = CoroutineScope(Job() + Dispatchers.Main)
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
myanimelist.ensureLoggedIn() scope.launch {
myanimelist.ensureLoggedIn()
val request = chain.request()
var response = chain.proceed(updateRequest(request))
if (response.code == 400) {
myanimelist.refreshLogin()
response = chain.proceed(updateRequest(request))
} }
val request = chain.request()
return chain.proceed(updateRequest(request))
return response
} }
private fun updateRequest(request: Request): Request { private fun updateRequest(request: Request): Request {
@ -46,13 +47,15 @@ class MyAnimeListInterceptor(private val myanimelist: Myanimelist): Interceptor
private fun updateFormBody(requestBody: RequestBody): RequestBody { private fun updateFormBody(requestBody: RequestBody): RequestBody {
val formString = bodyToString(requestBody) val formString = bodyToString(requestBody)
return "$formString${if (formString.isNotEmpty()) "&" else ""}${MyAnimeListApi.CSRF}=${myanimelist.getCSRF()}".toRequestBody(requestBody.contentType()) return "$formString${if (formString.isNotEmpty()) "&" else ""}${MyAnimeListApi.CSRF}=${myanimelist.getCSRF()}".toRequestBody(
requestBody.contentType()
)
} }
private fun updateJsonBody(requestBody: RequestBody): RequestBody { private fun updateJsonBody(requestBody: RequestBody): RequestBody {
val jsonString = bodyToString(requestBody) val jsonString = bodyToString(requestBody)
val newBody = JSONObject(jsonString) val newBody = JSONObject(jsonString)
.put(MyAnimeListApi.CSRF, myanimelist.getCSRF()) .put(MyAnimeListApi.CSRF, myanimelist.getCSRF())
return newBody.toString().toRequestBody(requestBody.contentType()) return newBody.toString().toRequestBody(requestBody.contentType())
} }

View File

@ -1,13 +0,0 @@
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)
}

View File

@ -7,74 +7,11 @@ 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 timber.log.Timber
import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class Shikimori(private val context: Context, id: Int) : TrackService(id) { 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" override val name = "Shikimori"
private val gson: Gson by injectLazy() private val gson: Gson by injectLazy()
@ -103,18 +40,64 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
} }
} }
override fun login(username: String, password: String) = login(password) override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)
}
override fun displayScore(track: Track): String {
return track.score.toInt().toString()
}
override suspend fun update(track: Track): Track {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED
}
return api.updateLibManga(track, getUsername())
}
override suspend fun bind(track: Track): Track {
val remoteTrack = api.findLibManga(track, getUsername())
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
return api.addLibManga(track, getUsername())
}
return track
}
override suspend fun search(query: String) = api.search(query)
override suspend fun refresh(track: Track): Track {
val remoteTrack = api.findLibManga(track, getUsername())
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
}
return track
}
override suspend fun login(username: String, password: String) = login(password)
suspend fun login(code: String): Boolean {
try {
val oauth = api.accessToken(code)
fun login(code: String): Completable {
return api.accessToken(code).map { oauth: OAuth? ->
interceptor.newAuth(oauth) interceptor.newAuth(oauth)
if (oauth != null) { val user = api.getCurrentUser()
val user = api.getCurrentUser() saveCredentials(user.toString(), oauth.access_token)
saveCredentials(user.toString(), oauth.access_token) return true
} } catch (e: java.lang.Exception) {
}.doOnError { Timber.e(e)
logout() logout()
}.toCompletable() return false
}
} }
fun saveToken(oauth: OAuth?) { fun saveToken(oauth: OAuth?) {
@ -135,4 +118,16 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
preferences.trackToken(this).set(null) preferences.trackToken(this).set(null)
interceptor.newAuth(null) interceptor.newAuth(null)
} }
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
}
} }

View File

@ -14,68 +14,67 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.asObservableSuccess
import okhttp3.* import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import rx.Observable
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) { class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInterceptor) {
private val gson: Gson by injectLazy() private val gson: Gson by injectLazy()
private val parser = JsonParser()
private val jsonime = "application/json; charset=utf-8".toMediaTypeOrNull() private val jsonime = "application/json; charset=utf-8".toMediaTypeOrNull()
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track, user_id: String): Observable<Track> { suspend fun updateLibManga(track: Track, user_id: String): Track = addLibManga(track, user_id)
val payload = jsonObject(
suspend fun addLibManga(track: Track, user_id: String): Track {
return withContext(Dispatchers.IO) {
val payload = jsonObject(
"user_rate" to jsonObject( "user_rate" to jsonObject(
"user_id" to user_id, "user_id" to user_id,
"target_id" to track.media_id, "target_id" to track.media_id,
"target_type" to "Manga", "target_type" to "Manga",
"chapters" to track.last_chapter_read, "chapters" to track.last_chapter_read,
"score" to track.score.toInt(), "score" to track.score.toInt(),
"status" to track.toShikimoriStatus() "status" to track.toShikimoriStatus()
) )
) )
val body = payload.toString().toRequestBody(jsonime) val body = payload.toString().toRequestBody(jsonime)
val request = Request.Builder() val request = Request.Builder()
.url("$apiUrl/v2/user_rates") .url("$apiUrl/v2/user_rates")
.post(body) .post(body)
.build() .build()
return authClient.newCall(request) authClient.newCall(request).execute()
.asObservableSuccess() track
.map { }
track
}
} }
fun updateLibManga(track: Track, user_id: String): Observable<Track> = addLibManga(track, user_id) suspend fun search(search: String): List<TrackSearch> {
return withContext(Dispatchers.IO) {
fun search(search: String): Observable<List<TrackSearch>> { val url = Uri.parse("$apiUrl/mangas").buildUpon()
val url = Uri.parse("$apiUrl/mangas").buildUpon()
.appendQueryParameter("order", "popularity") .appendQueryParameter("order", "popularity")
.appendQueryParameter("search", search) .appendQueryParameter("search", search)
.appendQueryParameter("limit", "20") .appendQueryParameter("limit", "20")
.build() .build()
val request = Request.Builder() val request = Request.Builder()
.url(url.toString()) .url(url.toString())
.get() .get()
.build() .build()
return authClient.newCall(request) val netResponse = authClient.newCall(request).execute()
.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) }
}
val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) {
throw Exception("Null Response")
}
val response = JsonParser.parseString(responseBody).array
response.map { jsonToSearch(it.obj) }
}
} }
private fun jsonToSearch(obj: JsonObject): TrackSearch { private fun jsonToSearch(obj: JsonObject): TrackSearch {
@ -104,56 +103,55 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
} }
} }
fun findLibManga(track: Track, user_id: String): Observable<Track?> { suspend fun findLibManga(track: Track, user_id: String): Track? {
val url = Uri.parse("$apiUrl/v2/user_rates").buildUpon() return withContext(Dispatchers.IO) {
val url = Uri.parse("$apiUrl/v2/user_rates").buildUpon()
.appendQueryParameter("user_id", user_id) .appendQueryParameter("user_id", user_id)
.appendQueryParameter("target_id", track.media_id.toString()) .appendQueryParameter("target_id", track.media_id.toString())
.appendQueryParameter("target_type", "Manga") .appendQueryParameter("target_type", "Manga")
.build() .build()
val request = Request.Builder() val request = Request.Builder()
.url(url.toString()) .url(url.toString())
.get() .get()
.build() .build()
val urlMangas = Uri.parse("$apiUrl/mangas").buildUpon() val urlMangas = Uri.parse("$apiUrl/mangas").buildUpon()
.appendPath(track.media_id.toString()) .appendPath(track.media_id.toString())
.build() .build()
val requestMangas = Request.Builder() val requestMangas = Request.Builder()
.url(urlMangas.toString()) .url(urlMangas.toString())
.get() .get()
.build() .build()
return authClient.newCall(requestMangas)
.asObservableSuccess() val requestMangasResponse = authClient.newCall(requestMangas).execute()
.map { netResponse -> val requestMangasBody = requestMangasResponse.body?.string().orEmpty()
val responseBody = netResponse.body?.string().orEmpty() val mangas = JsonParser.parseString(requestMangasBody).obj
parser.parse(responseBody).obj
}.flatMap { mangas -> val requestResponse = authClient.newCall(request).execute()
authClient.newCall(request) val requestResponseBody = requestResponse.body?.string().orEmpty()
.asObservableSuccess()
.map { netResponse -> if (requestResponseBody.isEmpty()) {
val responseBody = netResponse.body?.string().orEmpty() throw Exception("Null Response")
if (responseBody.isEmpty()) { }
throw Exception("Null Response") val response = JsonParser.parseString(requestResponseBody).array
} if (response.size() > 1) {
val response = parser.parse(responseBody).array throw Exception("Too much mangas in response")
if (response.size() > 1) { }
throw Exception("Too much mangas in response") val entry = response.map {
} jsonToTrack(it.obj, mangas)
val entry = response.map { }
jsonToTrack(it.obj, mangas) entry.firstOrNull()
} }
entry.firstOrNull()
}
}
} }
fun getCurrentUser(): Int { fun getCurrentUser(): Int {
val user = authClient.newCall(GET("$apiUrl/users/whoami")).execute().body?.string() val user = authClient.newCall(GET("$apiUrl/users/whoami")).execute().body?.string()
return parser.parse(user).obj["id"].asInt return JsonParser.parseString(user).obj["id"].asInt
} }
fun accessToken(code: String): Observable<OAuth> { suspend fun accessToken(code: String): OAuth {
return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse -> return withContext(Dispatchers.IO) {
val netResponse= client.newCall(accessTokenRequest(code)).execute()
val responseBody = netResponse.body?.string().orEmpty() val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) { if (responseBody.isEmpty()) {
throw Exception("Null Response") throw Exception("Null Response")
@ -162,20 +160,22 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
} }
} }
private fun accessTokenRequest(code: String) = POST(oauthUrl, private fun accessTokenRequest(code: String) = POST(
body = FormBody.Builder() oauthUrl,
.add("grant_type", "authorization_code") body = FormBody.Builder()
.add("client_id", clientId) .add("grant_type", "authorization_code")
.add("client_secret", clientSecret) .add("client_id", clientId)
.add("code", code) .add("client_secret", clientSecret)
.add("redirect_uri", redirectUrl) .add("code", code)
.build() .add("redirect_uri", redirectUrl)
.build()
) )
companion object { companion object {
private const val clientId = "1aaf4cf232372708e98b5abc813d795b539c5a916dbbfe9ac61bf02a360832cc" private const val clientId =
private const val clientSecret = "229942c742dd4cde803125d17d64501d91c0b12e14cb1e5120184d77d67024c0" "1aaf4cf232372708e98b5abc813d795b539c5a916dbbfe9ac61bf02a360832cc"
private const val clientSecret =
"229942c742dd4cde803125d17d64501d91c0b12e14cb1e5120184d77d67024c0"
private const val baseUrl = "https://shikimori.one" private const val baseUrl = "https://shikimori.one"
private const val apiUrl = "https://shikimori.one/api" private const val apiUrl = "https://shikimori.one/api"
@ -190,21 +190,20 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
} }
fun authUrl() = fun authUrl() =
Uri.parse(loginUrl).buildUpon() Uri.parse(loginUrl).buildUpon()
.appendQueryParameter("client_id", clientId) .appendQueryParameter("client_id", clientId)
.appendQueryParameter("redirect_uri", redirectUrl) .appendQueryParameter("redirect_uri", redirectUrl)
.appendQueryParameter("response_type", "code") .appendQueryParameter("response_type", "code")
.build() .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())
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()
)
} }
} }

View File

@ -22,3 +22,15 @@ fun toTrackStatus(status: String) = when (status) {
else -> throw Exception("Unknown status") else -> throw Exception("Unknown status")
} }
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)
}

View File

@ -1,20 +1,13 @@
package eu.kanade.tachiyomi.data.updater package eu.kanade.tachiyomi.data.updater
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.updater.devrepo.DevRepoUpdateChecker
import eu.kanade.tachiyomi.data.updater.github.GithubUpdateChecker import eu.kanade.tachiyomi.data.updater.github.GithubUpdateChecker
import rx.Observable import rx.Observable
abstract class UpdateChecker { abstract class UpdateChecker {
companion object { companion object {
fun getUpdateChecker(): UpdateChecker { fun getUpdateChecker(): UpdateChecker = GithubUpdateChecker()
return if (BuildConfig.DEBUG) {
DevRepoUpdateChecker()
} else {
GithubUpdateChecker()
}
}
} }
/** /**

View File

@ -1,14 +0,0 @@
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"
}
}

View File

@ -1,40 +0,0 @@
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()
}
}
}
}

View File

@ -1,10 +0,0 @@
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()
}

View File

@ -2,7 +2,6 @@ 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
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET import retrofit2.http.GET
import rx.Observable import rx.Observable
@ -19,7 +18,6 @@ interface GithubService {
val restAdapter = Retrofit.Builder() val restAdapter = Retrofit.Builder()
.baseUrl("https://api.github.com") .baseUrl("https://api.github.com")
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.client(Injekt.get<NetworkHelper>().client) .client(Injekt.get<NetworkHelper>().client)
.build() .build()
@ -28,6 +26,6 @@ interface GithubService {
} }
@GET("/repos/Jays2Kings/tachiyomiJ2K/releases/latest") @GET("/repos/Jays2Kings/tachiyomiJ2K/releases/latest")
fun getLatestVersion(): Observable<GithubRelease> suspend fun getLatestVersion(): GithubRelease
} }

View File

@ -2,11 +2,15 @@ package eu.kanade.tachiyomi.network
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.* import okhttp3.*
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import rx.Observable import rx.Observable
import rx.Producer import rx.Producer
import rx.Subscription import rx.Subscription
import java.io.BufferedReader
import java.io.IOException import java.io.IOException
import java.io.InputStreamReader
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.zip.GZIPInputStream
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@ -94,3 +98,25 @@ fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListene
return progressClient.newCall(request) return progressClient.newCall(request)
} }
fun MediaType.Companion.jsonType() : MediaType = "application/json; charset=utf-8".toMediaTypeOrNull()!!
fun Response.consumeBody(): String? {
use {
if (it.code != 200) throw Exception("HTTP error ${it.code}")
return it.body?.string()
}
}
fun Response.consumeXmlBody(): String? {
use { res ->
if (res.code != 200) throw Exception("Export list error")
BufferedReader(InputStreamReader(GZIPInputStream(res.body?.source()?.inputStream()))).use { reader ->
val sb = StringBuilder()
reader.forEachLine { line ->
sb.append(line)
}
return sb.toString()
}
}
}

View File

@ -101,6 +101,7 @@ import jp.wasabeef.glide.transformations.MaskTransformation
import kotlinx.android.synthetic.main.main_activity.* import kotlinx.android.synthetic.main.main_activity.*
import kotlinx.android.synthetic.main.manga_details_controller.* import kotlinx.android.synthetic.main.manga_details_controller.*
import kotlinx.android.synthetic.main.manga_header_item.* import kotlinx.android.synthetic.main.manga_header_item.*
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
import java.io.File import java.io.File
@ -927,10 +928,12 @@ class MangaDetailsController : BaseController,
} }
fun trackRefreshError(error: Exception) { fun trackRefreshError(error: Exception) {
Timber.e(error)
trackingBottomSheet?.onRefreshError(error) trackingBottomSheet?.onRefreshError(error)
} }
fun trackSearchError(error: Exception) { fun trackSearchError(error: Exception) {
Timber.e(error)
trackingBottomSheet?.onSearchResultsError(error) trackingBottomSheet?.onSearchResultsError(error)
} }

View File

@ -691,7 +691,7 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
val list = trackList.filter { it.track != null }.map { item -> val list = trackList.filter { it.track != null }.map { item ->
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val trackItem = try { val trackItem = try {
item.service.refresh(item.track!!).toBlocking().single() item.service.refresh(item.track!!)
} catch (e: Exception) { } catch (e: Exception) {
trackError(e) trackError(e)
null null
@ -710,7 +710,7 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
fun trackSearch(query: String, service: TrackService) { fun trackSearch(query: String, service: TrackService) {
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
val results = try {service.search(query).toBlocking().single() } val results = try {service.search(query) }
catch (e: Exception) { catch (e: Exception) {
withContext(Dispatchers.Main) { controller.trackSearchError(e) } withContext(Dispatchers.Main) { controller.trackSearchError(e) }
null } null }
@ -725,7 +725,7 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
item.manga_id = manga.id!! item.manga_id = manga.id!!
launch { launch {
val binding = try { service.bind(item).toBlocking().single() } val binding = try { service.bind(item) }
catch (e: Exception) { catch (e: Exception) {
trackError(e) trackError(e)
null null
@ -745,7 +745,7 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
private fun updateRemote(track: Track, service: TrackService) { private fun updateRemote(track: Track, service: TrackService) {
launch { launch {
val binding = try { service.update(track).toBlocking().single() } val binding = try { service.update(track) }
catch (e: Exception) { catch (e: Exception) {
trackError(e) trackError(e)
null null

View File

@ -24,6 +24,11 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.ImageUtil import eu.kanade.tachiyomi.util.system.ImageUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import rx.Completable import rx.Completable
import rx.Observable import rx.Observable
import rx.Subscription import rx.Subscription
@ -40,11 +45,11 @@ import java.util.concurrent.TimeUnit
* Presenter used by the activity to perform background operations. * Presenter used by the activity to perform background operations.
*/ */
class ReaderPresenter( class ReaderPresenter(
private val db: DatabaseHelper = Injekt.get(), private val db: DatabaseHelper = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(), private val coverCache: CoverCache = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get() private val preferences: PreferencesHelper = Injekt.get()
) : BasePresenter<ReaderActivity>() { ) : BasePresenter<ReaderActivity>() {
/** /**
@ -87,19 +92,19 @@ class ReaderPresenter(
val dbChapters = db.getChapters(manga).executeAsBlocking() val dbChapters = db.getChapters(manga).executeAsBlocking()
val selectedChapter = dbChapters.find { it.id == chapterId } val selectedChapter = dbChapters.find { it.id == chapterId }
?: error("Requested chapter of id $chapterId not found in chapter list") ?: error("Requested chapter of id $chapterId not found in chapter list")
val chaptersForReader = val chaptersForReader =
if (preferences.skipRead()) { if (preferences.skipRead()) {
val list = dbChapters.filter { !it.read }.toMutableList() val list = dbChapters.filter { !it.read }.toMutableList()
val find = list.find { it.id == chapterId } val find = list.find { it.id == chapterId }
if (find == null) { if (find == null) {
list.add(selectedChapter) list.add(selectedChapter)
}
list
} else {
dbChapters
} }
list
} else {
dbChapters
}
when (manga.sorting) { when (manga.sorting) {
Manga.SORTING_SOURCE -> ChapterLoadBySource().get(chaptersForReader) Manga.SORTING_SOURCE -> ChapterLoadBySource().get(chaptersForReader)
@ -170,12 +175,12 @@ class ReaderPresenter(
if (!needsInit()) return if (!needsInit()) return
db.getManga(mangaId).asRxObservable() db.getManga(mangaId).asRxObservable()
.first() .first()
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.doOnNext { init(it, initialChapterId) } .doOnNext { init(it, initialChapterId) }
.subscribeFirst({ _, _ -> .subscribeFirst({ _, _ ->
// Ignore onNext event // Ignore onNext event
}, ReaderActivity::setInitialChapterError) }, ReaderActivity::setInitialChapterError)
} }
fun init(mangaId: Long, chapterUrl: String) { fun init(mangaId: Long, chapterUrl: String) {
@ -207,13 +212,13 @@ class ReaderPresenter(
// Read chapterList from an io thread because it's retrieved lazily and would block main. // Read chapterList from an io thread because it's retrieved lazily and would block main.
activeChapterSubscription?.unsubscribe() activeChapterSubscription?.unsubscribe()
activeChapterSubscription = Observable activeChapterSubscription = Observable
.fromCallable { chapterList.first { chapterId == it.chapter.id } } .fromCallable { chapterList.first { chapterId == it.chapter.id } }
.flatMap { getLoadObservable(loader!!, it) } .flatMap { getLoadObservable(loader!!, it) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ _, _ -> .subscribeFirst({ _, _ ->
// Ignore onNext event // Ignore onNext event
}, ReaderActivity::setInitialChapterError) }, ReaderActivity::setInitialChapterError)
} }
/** /**
@ -224,27 +229,29 @@ class ReaderPresenter(
* Callers must also handle the onError event. * Callers must also handle the onError event.
*/ */
private fun getLoadObservable( private fun getLoadObservable(
loader: ChapterLoader, loader: ChapterLoader,
chapter: ReaderChapter chapter: ReaderChapter
): Observable<ViewerChapters> { ): Observable<ViewerChapters> {
return loader.loadChapter(chapter) return loader.loadChapter(chapter)
.andThen(Observable.fromCallable { .andThen(Observable.fromCallable {
val chapterPos = chapterList.indexOf(chapter) val chapterPos = chapterList.indexOf(chapter)
ViewerChapters(chapter, ViewerChapters(
chapterList.getOrNull(chapterPos - 1), chapter,
chapterList.getOrNull(chapterPos + 1)) chapterList.getOrNull(chapterPos - 1),
}) chapterList.getOrNull(chapterPos + 1)
.observeOn(AndroidSchedulers.mainThread()) )
.doOnNext { newChapters -> })
val oldChapters = viewerChaptersRelay.value .observeOn(AndroidSchedulers.mainThread())
.doOnNext { newChapters ->
val oldChapters = viewerChaptersRelay.value
// Add new references first to avoid unnecessary recycling // Add new references first to avoid unnecessary recycling
newChapters.ref() newChapters.ref()
oldChapters?.unref() oldChapters?.unref()
viewerChaptersRelay.call(newChapters) viewerChaptersRelay.call(newChapters)
} }
} }
/** /**
@ -258,10 +265,10 @@ class ReaderPresenter(
activeChapterSubscription?.unsubscribe() activeChapterSubscription?.unsubscribe()
activeChapterSubscription = getLoadObservable(loader, chapter) activeChapterSubscription = getLoadObservable(loader, chapter)
.toCompletable() .toCompletable()
.onErrorComplete() .onErrorComplete()
.subscribe() .subscribe()
.also(::add) .also(::add)
} }
/** /**
@ -276,13 +283,13 @@ class ReaderPresenter(
activeChapterSubscription?.unsubscribe() activeChapterSubscription?.unsubscribe()
activeChapterSubscription = getLoadObservable(loader, chapter) activeChapterSubscription = getLoadObservable(loader, chapter)
.doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) } .doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) }
.doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) } .doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) }
.subscribeFirst({ view, _ -> .subscribeFirst({ view, _ ->
view.moveToPageIndex(0) view.moveToPageIndex(0)
}, { _, _ -> }, { _, _ ->
// Ignore onError event, viewers handle that state // Ignore onError event, viewers handle that state
}) })
} }
/** /**
@ -299,12 +306,12 @@ class ReaderPresenter(
val loader = loader ?: return val loader = loader ?: return
loader.loadChapter(chapter) loader.loadChapter(chapter)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
// Update current chapters whenever a chapter is preloaded // Update current chapters whenever a chapter is preloaded
.doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) } .doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) }
.onErrorComplete() .onErrorComplete()
.subscribe() .subscribe()
.also(::add) .also(::add)
} }
/** /**
@ -348,9 +355,9 @@ class ReaderPresenter(
*/ */
private fun saveChapterProgress(chapter: ReaderChapter) { private fun saveChapterProgress(chapter: ReaderChapter) {
db.updateChapterProgress(chapter.chapter).asRxCompletable() db.updateChapterProgress(chapter.chapter).asRxCompletable()
.onErrorComplete() .onErrorComplete()
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.subscribe() .subscribe()
} }
/** /**
@ -412,18 +419,18 @@ class ReaderPresenter(
db.updateMangaViewer(manga).executeAsBlocking() db.updateMangaViewer(manga).executeAsBlocking()
Observable.timer(250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) Observable.timer(250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ -> .subscribeFirst({ view, _ ->
val currChapters = viewerChaptersRelay.value val currChapters = viewerChaptersRelay.value
if (currChapters != null) { if (currChapters != null) {
// Save current page // Save current page
val currChapter = currChapters.currChapter val currChapter = currChapters.currChapter
currChapter.requestedPage = currChapter.chapter.last_page_read currChapter.requestedPage = currChapter.chapter.last_page_read
// Emit manga and chapters to the new viewer // Emit manga and chapters to the new viewer
view.setManga(manga) view.setManga(manga)
view.setChapters(currChapters) view.setChapters(currChapters)
} }
}) })
} }
/** /**
@ -439,7 +446,7 @@ class ReaderPresenter(
// Build destination file. // Build destination file.
val filename = DiskUtil.buildValidFilename( val filename = DiskUtil.buildValidFilename(
"${manga.currentTitle()} - ${chapter.name}".take(225) "${manga.currentTitle()} - ${chapter.name}".take(225)
) + " - ${page.number}.${type.extension}" ) + " - ${page.number}.${type.extension}"
val destFile = File(directory, filename) val destFile = File(directory, filename)
@ -464,23 +471,25 @@ class ReaderPresenter(
notifier.onClear() notifier.onClear()
// Pictures directory. // Pictures directory.
val destDir = File(Environment.getExternalStorageDirectory().absolutePath + val destDir = File(
Environment.getExternalStorageDirectory().absolutePath +
File.separator + Environment.DIRECTORY_PICTURES + File.separator + Environment.DIRECTORY_PICTURES +
File.separator + "Tachiyomi") File.separator + "Tachiyomi"
)
// Copy file in background. // Copy file in background.
Observable.fromCallable { saveImage(page, destDir, manga) } Observable.fromCallable { saveImage(page, destDir, manga) }
.doOnNext { file -> .doOnNext { file ->
DiskUtil.scanMedia(context, file) DiskUtil.scanMedia(context, file)
notifier.onComplete(file) notifier.onComplete(file)
} }
.doOnError { notifier.onError(it.message) } .doOnError { notifier.onError(it.message) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribeFirst( .subscribeFirst(
{ view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) }, { view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) },
{ view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) } { view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) }
) )
} }
/** /**
@ -498,13 +507,13 @@ class ReaderPresenter(
val destDir = File(context.cacheDir, "shared_image") val destDir = File(context.cacheDir, "shared_image")
Observable.fromCallable { destDir.deleteRecursively() } // Keep only the last shared file Observable.fromCallable { destDir.deleteRecursively() } // Keep only the last shared file
.map { saveImage(page, destDir, manga) } .map { saveImage(page, destDir, manga) }
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribeFirst( .subscribeFirst(
{ view, file -> view.onShareImageResult(file) }, { view, file -> view.onShareImageResult(file) },
{ _, _ -> /* Empty */ } { _, _ -> /* Empty */ }
) )
} }
/** /**
@ -516,29 +525,29 @@ class ReaderPresenter(
val stream = page.stream ?: return val stream = page.stream ?: return
Observable Observable
.fromCallable { .fromCallable {
if (manga.source == LocalSource.ID) { if (manga.source == LocalSource.ID) {
val context = Injekt.get<Application>() val context = Injekt.get<Application>()
LocalSource.updateCover(context, manga, stream()) LocalSource.updateCover(context, manga, stream())
R.string.cover_updated R.string.cover_updated
SetAsCoverResult.Success
} else {
val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
if (manga.favorite) {
coverCache.copyToCache(thumbUrl, stream())
MangaImpl.setLastCoverFetch(manga.id!!, Date().time)
SetAsCoverResult.Success SetAsCoverResult.Success
} else { } else {
val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found") SetAsCoverResult.AddToLibraryFirst
if (manga.favorite) {
coverCache.copyToCache(thumbUrl, stream())
MangaImpl.setLastCoverFetch(manga.id!!, Date().time)
SetAsCoverResult.Success
} else {
SetAsCoverResult.AddToLibraryFirst
}
} }
} }
.subscribeOn(Schedulers.io()) }
.observeOn(AndroidSchedulers.mainThread()) .subscribeOn(Schedulers.io())
.subscribeFirst( .observeOn(AndroidSchedulers.mainThread())
{ view, result -> view.onSetAsCoverResult(result) }, .subscribeFirst(
{ view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) } { view, result -> view.onSetAsCoverResult(result) },
) { view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) }
)
} }
/** /**
@ -568,27 +577,24 @@ class ReaderPresenter(
val trackManager = Injekt.get<TrackManager>() val trackManager = Injekt.get<TrackManager>()
db.getTracks(manga).asRxSingle() // We wan't these to execute even if the presenter is destroyed so launch on GlobalScope
.flatMapCompletable { trackList -> GlobalScope.launch {
Completable.concat(trackList.map { track -> withContext(Dispatchers.IO) {
val service = trackManager.getService(track.sync_id) val trackList = db.getTracks(manga).executeAsBlocking()
if (service != null && service.isLogged && chapterRead > track.last_chapter_read) { trackList.map { track ->
val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged && chapterRead > track.last_chapter_read) {
try {
track.last_chapter_read = chapterRead track.last_chapter_read = chapterRead
service.update(track)
// We wan't these to execute even if the presenter is destroyed and leaks db.insertTrack(track).executeAsBlocking()
// for a while. The view can still be garbage collected. } catch (e: Exception) {
Observable.defer { service.update(track) } Timber.e(e)
.map { db.insertTrack(track).executeAsBlocking() }
.toCompletable()
.onErrorComplete()
} else {
Completable.complete()
} }
}) }
} }
.onErrorComplete() }
.subscribeOn(Schedulers.io()) }
.subscribe()
} }
/** /**
@ -604,19 +610,19 @@ class ReaderPresenter(
if (removeAfterReadSlots == -1) return if (removeAfterReadSlots == -1) return
Completable Completable
.fromCallable { .fromCallable {
// Position of the read chapter // Position of the read chapter
val position = chapterList.indexOf(chapter) val position = chapterList.indexOf(chapter)
// Retrieve chapter to delete according to preference // Retrieve chapter to delete according to preference
val chapterToDelete = chapterList.getOrNull(position - removeAfterReadSlots) val chapterToDelete = chapterList.getOrNull(position - removeAfterReadSlots)
if (chapterToDelete != null) { if (chapterToDelete != null) {
downloadManager.enqueueDeleteChapters(listOf(chapterToDelete.chapter), manga) downloadManager.enqueueDeleteChapters(listOf(chapterToDelete.chapter), manga)
}
} }
.onErrorComplete() }
.subscribeOn(Schedulers.io()) .onErrorComplete()
.subscribe() .subscribeOn(Schedulers.io())
.subscribe()
} }
/** /**
@ -625,9 +631,8 @@ class ReaderPresenter(
*/ */
private fun deletePendingChapters() { private fun deletePendingChapters() {
Completable.fromCallable { downloadManager.deletePendingChapters() } Completable.fromCallable { downloadManager.deletePendingChapters() }
.onErrorComplete() .onErrorComplete()
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.subscribe() .subscribe()
} }
} }

View File

@ -2,21 +2,26 @@ package eu.kanade.tachiyomi.ui.setting.track
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import android.view.Gravity.CENTER import android.view.Gravity.CENTER
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import androidx.appcompat.app.AppCompatActivity
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import rx.android.schedulers.AndroidSchedulers import kotlinx.coroutines.CoroutineScope
import rx.schedulers.Schedulers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class AnilistLoginActivity : AppCompatActivity() { class AnilistLoginActivity : AppCompatActivity() {
private val trackManager: TrackManager by injectLazy() private val trackManager: TrackManager by injectLazy()
private val scope = CoroutineScope(Job() + Dispatchers.Main)
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
@ -26,20 +31,21 @@ class AnilistLoginActivity : AppCompatActivity() {
val regex = "(?:access_token=)(.*?)(?:&)".toRegex() val regex = "(?:access_token=)(.*?)(?:&)".toRegex()
val matchResult = regex.find(intent.data?.fragment.toString()) val matchResult = regex.find(intent.data?.fragment.toString())
if (matchResult?.groups?.get(1) != null) { if (matchResult?.groups?.get(1) != null) {
trackManager.aniList.login(matchResult.groups[1]!!.value) scope.launch {
.subscribeOn(Schedulers.io()) trackManager.aniList.login(matchResult.groups[1]!!.value)
.observeOn(AndroidSchedulers.mainThread()) returnToSettings()
.subscribe({ }
returnToSettings()
}, {
returnToSettings()
})
} else { } else {
trackManager.aniList.logout() trackManager.aniList.logout()
returnToSettings() returnToSettings()
} }
} }
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
private fun returnToSettings() { private fun returnToSettings() {
finish() finish()
@ -47,5 +53,4 @@ class AnilistLoginActivity : AppCompatActivity() {
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent) startActivity(intent)
} }
} }

View File

@ -2,13 +2,18 @@ package eu.kanade.tachiyomi.ui.setting.track
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import android.view.Gravity.CENTER import android.view.Gravity.CENTER
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import androidx.appcompat.app.AppCompatActivity
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import rx.android.schedulers.AndroidSchedulers import rx.android.schedulers.AndroidSchedulers
import rx.schedulers.Schedulers import rx.schedulers.Schedulers
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@ -17,6 +22,8 @@ class BangumiLoginActivity : AppCompatActivity() {
private val trackManager: TrackManager by injectLazy() private val trackManager: TrackManager by injectLazy()
private val scope = CoroutineScope(Job() + Dispatchers.Main)
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
@ -25,14 +32,10 @@ class BangumiLoginActivity : AppCompatActivity() {
val code = intent.data?.getQueryParameter("code") val code = intent.data?.getQueryParameter("code")
if (code != null) { if (code != null) {
trackManager.bangumi.login(code) scope.launch {
.subscribeOn(Schedulers.io()) trackManager.bangumi.login(code)
.observeOn(AndroidSchedulers.mainThread()) returnToSettings()
.subscribe({ }
returnToSettings()
}, {
returnToSettings()
})
} else { } else {
trackManager.bangumi.logout() trackManager.bangumi.logout()
returnToSettings() returnToSettings()
@ -46,5 +49,4 @@ class BangumiLoginActivity : AppCompatActivity() {
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent) startActivity(intent)
} }
} }

View File

@ -2,21 +2,25 @@ package eu.kanade.tachiyomi.ui.setting.track
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import android.view.Gravity.CENTER import android.view.Gravity.CENTER
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import androidx.appcompat.app.AppCompatActivity
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import rx.android.schedulers.AndroidSchedulers import kotlinx.coroutines.CoroutineScope
import rx.schedulers.Schedulers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
class ShikimoriLoginActivity : AppCompatActivity() { class ShikimoriLoginActivity : AppCompatActivity() {
private val trackManager: TrackManager by injectLazy() private val trackManager: TrackManager by injectLazy()
private val scope = CoroutineScope(Job() + Dispatchers.Main)
override fun onCreate(savedState: Bundle?) { override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState) super.onCreate(savedState)
@ -25,14 +29,10 @@ class ShikimoriLoginActivity : AppCompatActivity() {
val code = intent.data?.getQueryParameter("code") val code = intent.data?.getQueryParameter("code")
if (code != null) { if (code != null) {
trackManager.shikimori.login(code) scope.launch {
.subscribeOn(Schedulers.io()) trackManager.shikimori.login(code)
.observeOn(AndroidSchedulers.mainThread()) returnToSettings()
.subscribe({ }
returnToSettings()
}, {
returnToSettings()
})
} else { } else {
trackManager.shikimori.logout() trackManager.shikimori.logout()
returnToSettings() returnToSettings()
@ -46,5 +46,4 @@ class ShikimoriLoginActivity : AppCompatActivity() {
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent) startActivity(intent)
} }
} }

View File

@ -13,21 +13,27 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.base.controller.DialogController import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.widget.SimpleTextWatcher import eu.kanade.tachiyomi.widget.SimpleTextWatcher
import kotlinx.android.synthetic.main.pref_account_login.view.login import kotlinx.android.synthetic.main.pref_account_login.view.*
import kotlinx.android.synthetic.main.pref_account_login.view.password import kotlinx.coroutines.CoroutineScope
import kotlinx.android.synthetic.main.pref_account_login.view.show_password import kotlinx.coroutines.Dispatchers
import kotlinx.android.synthetic.main.pref_account_login.view.username_label import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import rx.Subscription import rx.Subscription
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
abstract class LoginDialogPreference(private val usernameLabel: String? = null, bundle: Bundle? = null) : abstract class LoginDialogPreference(
DialogController(bundle) { private val usernameLabel: String? = null,
bundle: Bundle? = null
) :
DialogController(bundle) {
var v: View? = null var v: View? = null
private set private set
val preferences: PreferencesHelper by injectLazy() val preferences: PreferencesHelper by injectLazy()
val scope = CoroutineScope(Job() + Dispatchers.Main)
var requestSubscription: Subscription? = null var requestSubscription: Subscription? = null
open var canLogout = false open var canLogout = false
@ -46,7 +52,7 @@ abstract class LoginDialogPreference(private val usernameLabel: String? = null,
return dialog return dialog
} }
open fun logout() { } open fun logout() {}
fun onViewCreated(view: View) { fun onViewCreated(view: View) {
v = view.apply { v = view.apply {
@ -76,7 +82,6 @@ abstract class LoginDialogPreference(private val usernameLabel: String? = null,
} }
}) })
} }
} }
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) { override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
@ -87,11 +92,11 @@ abstract class LoginDialogPreference(private val usernameLabel: String? = null,
} }
open fun onDialogClosed() { open fun onDialogClosed() {
scope.cancel()
requestSubscription?.unsubscribe() requestSubscription?.unsubscribe()
} }
protected abstract fun checkLogin() protected abstract fun checkLogin()
protected abstract fun setCredentialsOnView(view: View) protected abstract fun setCredentialsOnView(view: View)
} }

View File

@ -7,13 +7,12 @@ import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.android.synthetic.main.pref_account_login.view.* import kotlinx.android.synthetic.main.pref_account_login.view.*
import rx.android.schedulers.AndroidSchedulers import kotlinx.coroutines.launch
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
class TrackLoginDialog(usernameLabel: String? = null, bundle: Bundle? = null) : class TrackLoginDialog(usernameLabel: String? = null, bundle: Bundle? = null) :
LoginDialogPreference(usernameLabel, bundle) { LoginDialogPreference(usernameLabel, bundle) {
private val service = Injekt.get<TrackManager>().getService(args.getInt("key"))!! private val service = Injekt.get<TrackManager>().getService(args.getInt("key"))!!
@ -22,7 +21,7 @@ class TrackLoginDialog(usernameLabel: String? = null, bundle: Bundle? = null) :
constructor(service: TrackService) : this(service, null) constructor(service: TrackService) : this(service, null)
constructor(service: TrackService, usernameLabel: String?) : constructor(service: TrackService, usernameLabel: String?) :
this(usernameLabel, Bundle().apply { putInt("key", service.id) }) this(usernameLabel, Bundle().apply { putInt("key", service.id) })
override fun setCredentialsOnView(view: View) = with(view) { override fun setCredentialsOnView(view: View) = with(view) {
dialog_title.text = context.getString(R.string.login_title, service.name) dialog_title.text = context.getString(R.string.login_title, service.name)
@ -31,7 +30,6 @@ class TrackLoginDialog(usernameLabel: String? = null, bundle: Bundle? = null) :
} }
override fun checkLogin() { override fun checkLogin() {
requestSubscription?.unsubscribe()
v?.apply { v?.apply {
if (username.text.isEmpty() || password.text.isEmpty()) if (username.text.isEmpty() || password.text.isEmpty())
@ -41,17 +39,27 @@ class TrackLoginDialog(usernameLabel: String? = null, bundle: Bundle? = null) :
val user = username.text.toString() val user = username.text.toString()
val pass = password.text.toString() val pass = password.text.toString()
requestSubscription = service.login(user, pass) scope.launch {
.subscribeOn(Schedulers.io()) try {
.observeOn(AndroidSchedulers.mainThread()) val result = service.login(user, pass)
.subscribe({ if (result) {
dialog?.dismiss() dialog?.dismiss()
context.toast(R.string.login_success) context.toast(R.string.login_success)
}, { error -> } else {
login.progress = -1 errorResult(this@apply)
login.setText(R.string.unknown_error) }
error.message?.let { context.toast(it) } } catch (error: Exception) {
}) errorResult(this@apply)
error.message?.let { context.toast(it) }
}
}
}
}
fun errorResult(view: View?) {
v?.apply {
login.progress = -1
login.setText(R.string.unknown_error)
} }
} }
@ -70,5 +78,4 @@ class TrackLoginDialog(usernameLabel: String? = null, bundle: Bundle? = null) :
interface Listener { interface Listener {
fun trackDialogClosed(service: TrackService) fun trackDialogClosed(service: TrackService)
} }
} }