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,15 +296,15 @@ 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) {
try {
service.refresh(track) service.refresh(track)
.doOnNext { db.insertTrack(it).executeAsBlocking() } db.insertTrack(track).executeAsBlocking()
.onErrorReturn { }catch (e : Exception){
errors.add("${manga.title} - ${it.message}") errors.add("${manga.title} - ${e.message}")
track
} }
} else { } else {
errors.add("${manga.title} - ${service?.name} not logged in") errors.add("${manga.title} - ${service?.name} not logged in")

View File

@ -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))
} }
@ -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)
} }
if (target == Target.CHAPTERS) {
job = GlobalScope.launch(handler) { job = GlobalScope.launch(handler) {
updateChaptersJob(mangaToAdd) 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
@ -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,7 +386,11 @@ class LibraryUpdateService(
cancelProgressNotification() cancelProgressNotification()
} }
private suspend fun updateMangaChapters(manga: LibraryManga, progess: Int, shouldDownload: Boolean): private suspend fun updateMangaChapters(
manga: LibraryManga,
progess: Int,
shouldDownload: Boolean
):
Boolean { Boolean {
try { try {
var hasDownloads = false var hasDownloads = false
@ -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
} }
@ -475,37 +485,32 @@ class LibraryUpdateService(
* 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() val tracks = db.getTracks(manga).executeAsBlocking()
Observable.from(tracks) tracks.forEach { track ->
.concatMap { track ->
val service = trackManager.getService(track.sync_id) val service = trackManager.getService(track.sync_id)
if (service != null && service in loggedServices) { if (service != null && service in loggedServices) {
try {
service.refresh(track) service.refresh(track)
.doOnNext { db.insertTrack(it).executeAsBlocking() } db.insertTrack(track).executeAsBlocking()
.onErrorReturn { track } } catch (e: Exception) {
} else { Timber.e(e)
Observable.empty() }
} }
} }
.map { manga }
} }
.doOnCompleted {
cancelProgressNotification() cancelProgressNotification()
} }
}
/** /**
* Shows the notification containing the currently updating manga and the progress. * Shows the notification containing the currently updating manga and the progress.
@ -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,32 +567,48 @@ 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(
Notifications.ID_NEW_CHAPTERS,
notification(Notifications.CHANNEL_NEW_CHAPTERS) {
setSmallIcon(R.drawable.ic_tachi) setSmallIcon(R.drawable.ic_tachi)
setLargeIcon(notificationBitmap) setLargeIcon(notificationBitmap)
setContentTitle(getString(R.string.notification_new_chapters)) setContentTitle(getString(R.string.notification_new_chapters))
color = ContextCompat.getColor(applicationContext, R.color.colorAccent) color = ContextCompat.getColor(applicationContext, R.color.colorAccent)
if (updates.size > 1) { if (updates.size > 1) {
setContentText(resources.getQuantityString(R.plurals setContentText(
resources.getQuantityString(
R.plurals
.notification_new_chapters_text, .notification_new_chapters_text,
updates.size, updates.size)) updates.size, updates.size
setStyle(NotificationCompat.BigTextStyle().bigText(updates.keys.joinToString("\n") { )
)
setStyle(
NotificationCompat.BigTextStyle()
.bigText(updates.keys.joinToString("\n") {
it.currentTitle().chop(45) it.currentTitle().chop(45)
})) })
} )
else { } else {
setContentText(updates.keys.first().currentTitle().chop(45)) setContentText(updates.keys.first().currentTitle().chop(45))
} }
priority = NotificationCompat.PRIORITY_HIGH priority = NotificationCompat.PRIORITY_HIGH

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,32 +107,25 @@ 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)
@ -161,35 +133,36 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
// 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 fun refresh(track: Track): Observable<Track> { override suspend fun refresh(track: Track): Track {
return api.getLibManga(track, getUsername().toInt()) val remoteTrack = api.getLibManga(track, getUsername().toInt())
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
track return track
}
} }
override fun login(username: String, password: String) = login(password) override suspend fun login(username: String, password: String) = login(password)
fun login(token: String): Completable { suspend fun login(token: String): Boolean {
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)
saveCredentials(currentUser.first.toString(), oauth.access_token)
true
} catch (e: Exception) {
Timber.e(e)
logout() logout()
}.toCompletable() 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 {
// 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)
update(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 ->
if (remoteTrack != null) {
track.total_chapters = remoteTrack.total_chapters track.total_chapters = remoteTrack.total_chapters
track.status = remoteTrack.status track.status = remoteTrack.status
} }
track return 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
}.doOnError { } catch (e: Exception) {
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,22 +10,22 @@ 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())
@ -34,15 +34,13 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
.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
return withContext(Dispatchers.IO) {
val body = FormBody.Builder() val body = FormBody.Builder()
.add("watched_eps", track.last_chapter_read.toString()) .add("watched_eps", track.last_chapter_read.toString())
.build() .build()
@ -59,31 +57,25 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
.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> {
return withContext(Dispatchers.IO) {
val url = Uri.parse( val url = Uri.parse(
"$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}").buildUpon() "$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() val netResponse = authClient.newCall(request).await()
.map { netResponse ->
var responseBody = netResponse.body?.string().orEmpty() var responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) { if (responseBody.isEmpty()) {
throw Exception("Null Response") throw Exception("Null Response")
@ -91,10 +83,13 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
if (responseBody.contains("\"code\":404")) { if (responseBody.contains("\"code\":404")) {
responseBody = "{\"results\":0,\"list\":[]}" responseBody = "{\"results\":0,\"list\":[]}"
} }
val response = parser.parse(responseBody).obj["list"]?.array val response = JsonParser.parseString(responseBody).obj["list"]?.array
response?.filter { it.obj["type"].asInt == 1 }?.map { jsonToSearch(it.obj) } 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,23 +114,20 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
} }
} }
fun findLibManga(track: Track): Observable<Track?> { suspend fun findLibManga(track: Track): Track? {
return withContext(Dispatchers.IO) {
val urlMangas = "$apiUrl/subject/${track.media_id}" val urlMangas = "$apiUrl/subject/${track.media_id}"
val requestMangas = Request.Builder() val requestMangas = Request.Builder()
.url(urlMangas) .url(urlMangas)
.get() .get()
.build() .build()
val netResponse = authClient.newCall(requestMangas).execute()
return authClient.newCall(requestMangas)
.asObservableSuccess()
.map { netResponse ->
// get comic info
val responseBody = netResponse.body?.string().orEmpty() val responseBody = netResponse.body?.string().orEmpty()
jsonToTrack(parser.parse(responseBody).obj) jsonToTrack(JsonParser.parseString(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)
@ -144,28 +136,27 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
.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 resp = netResponse.body?.string()
val coll = gson.fromJson(resp, Collection::class.java) val coll = gson.fromJson(resp, Collection::class.java)
track.status = coll.status?.id!! track.status = coll.status?.id!!
track.last_chapter_read = coll.ep_status!! track.last_chapter_read = coll.ep_status!!
track return 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(
oauthUrl,
body = FormBody.Builder() body = FormBody.Builder()
.add("grant_type", "authorization_code") .add("grant_type", "authorization_code")
.add("client_id", clientId) .add("client_id", clientId)
@ -198,14 +189,15 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept
.appendQueryParameter("redirect_uri", redirectUrl) .appendQueryParameter("redirect_uri", redirectUrl)
.build() .build()
fun refreshTokenRequest(token: String) = POST(oauthUrl, fun refreshTokenRequest(token: String) = POST(
oauthUrl,
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)
.add("redirect_uri", redirectUrl) .add("redirect_uri", redirectUrl)
.build()) .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
update(track) return update(track)
} else { } else {
track.score = DEFAULT_SCORE track.score = DEFAULT_SCORE
track.status = DEFAULT_STATUS track.status = DEFAULT_STATUS
add(track) return api.addLibManga(track, getUserId())
}
} }
} }
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
track return 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,10 +14,17 @@ 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) {
@ -22,7 +34,6 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
.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)
@ -30,7 +41,6 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
.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)
@ -38,12 +48,10 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
.baseUrl(algoliaUrl) .baseUrl(algoliaUrl)
.client(client) .client(client)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build() .build()
.create(KitsuApi.AgoliaSearchRest::class.java) .create(KitsuApi.AgoliaSearchRest::class.java)
fun addLibManga(track: Track, userId: String): Observable<Track> { suspend fun addLibManga(track: Track, userId: String): Track {
return Observable.defer {
// @formatter:off // @formatter:off
val data = jsonObject( val data = jsonObject(
"type" to "libraryEntries", "type" to "libraryEntries",
@ -67,16 +75,12 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
) )
) )
rest.addLibManga(jsonObject("data" to data)) val json = rest.addLibManga(jsonObject("data" to data))
.map { json ->
track.media_id = json["data"]["id"].int track.media_id = json["data"]["id"].int
track return track
}
}
} }
fun updateLibManga(track: Track): Observable<Track> { suspend fun updateLibManga(track: Track): Track {
return Observable.defer {
// @formatter:off // @formatter:off
val data = jsonObject( val data = jsonObject(
"type" to "libraryEntries", "type" to "libraryEntries",
@ -90,159 +94,148 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
// @formatter:on // @formatter:on
rest.updateLibManga(track.media_id, jsonObject("data" to data)) rest.updateLibManga(track.media_id, jsonObject("data" to data))
.map { track } return track
}
} }
suspend fun search(query: String): List<TrackSearch> {
fun search(query: String): Observable<List<TrackSearch>> { val key = searchRest.getKey()["media"].asJsonObject["key"].string
return searchRest return algoliaSearch(key, query)
.getKey().map { json ->
json["media"].asJsonObject["key"].string
}.flatMap { key ->
algoliaSearch(key, query)
}
} }
private suspend fun algoliaSearch(key: String, query: String): List<TrackSearch> {
private fun algoliaSearch(key: String, query: String): Observable<List<TrackSearch>> {
val jsonObject = jsonObject("params" to "query=$query$algoliaFilter") val jsonObject = jsonObject("params" to "query=$query$algoliaFilter")
return algoliaRest val json = algoliaRest.getSearchQuery(algoliaAppId, key, jsonObject)
.getSearchQuery(algoliaAppId, key, jsonObject)
.map { json ->
val data = json["hits"].array val data = json["hits"].array
data.map { KitsuSearchManga(it.obj) } return data.map { KitsuSearchManga(it.obj) }
.filter { it.subType != "novel" } .filter { it.subType != "novel" }
.map { it.toTrack() } .map { it.toTrack() }
} }
}
fun findLibManga(track: Track, userId: String): Observable<Track?> { suspend fun findLibManga(track: Track, userId: String): Track? {
return rest.findLibManga(track.media_id, userId) val json = rest.findLibManga(track.media_id, userId)
.map { json ->
val data = json["data"].array val data = json["data"].array
if (data.size() > 0) { return if (data.size() > 0) {
val manga = json["included"].array[0].obj val manga = json["included"].array[0].obj
KitsuLibManga(data[0].obj, manga).toTrack() KitsuLibManga(data[0].obj, manga).toTrack()
} else { } else {
null null
} }
} }
}
fun getLibManga(track: Track): Observable<Track> { suspend fun getLibManga(track: Track): Track {
return rest.getLibManga(track.media_id) val json = rest.getLibManga(track.media_id)
.map { json ->
val data = json["data"].array val data = json["data"].array
if (data.size() > 0) { if (data.size() > 0) {
val manga = json["included"].array[0].obj val manga = json["included"].array[0].obj
KitsuLibManga(data[0].obj, manga).toTrack() return KitsuLibManga(data[0].obj, manga).toTrack()
} else { } else {
throw Exception("Could not find manga") throw Exception("Could not find manga")
} }
} }
}
fun login(username: String, password: String): Observable<OAuth> { suspend fun login(username: String, password: String): OAuth {
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl(loginUrl) .baseUrl(loginUrl)
.client(client) .client(client)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build() .build()
.create(KitsuApi.LoginRest::class.java) .create(KitsuApi.LoginRest::class.java)
.requestAccessToken(username, password) .requestAccessToken(username, password)
} }
fun getCurrentUser(): Observable<String> { suspend fun getCurrentUser(): String {
return rest.getCurrentUser().map { it["data"].array[0]["id"].string } val currentUser = rest.getCurrentUser()
return currentUser["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,9 +52,8 @@ 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)
@ -84,35 +61,37 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
// 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) return api.addLibManga(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
track return 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) }
.filter { it.title.contains(realQuery, true) }
.toList()
} else { } else {
client.newCall(GET(searchUrl(query))) val realQuery = query.take(100)
.asObservable() val response = client.newCall(GET(searchUrl(realQuery))).await()
.flatMap { response -> val matches = Jsoup.parse(response.consumeBody())
Observable.from(Jsoup.parse(response.consumeBody())
.select("div.js-categories-seasonal.js-block-list.list") .select("div.js-categories-seasonal.js-block-list.list")
.select("table").select("tbody") .select("table").select("tbody")
.select("tr").drop(1)) .select("tr").drop(1)
}
.filter { row -> matches.filter { row -> row.select(TD)[2].text() != "Novel" }
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()
@ -66,58 +57,64 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
.toList() .toList()
} }
} }
fun addLibManga(track: Track): Observable<Track> {
return Observable.defer {
authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track)))
.asObservableSuccess()
.map { track }
}
} }
fun updateLibManga(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 = updateUrl(), body = mangaPostPayload(track))) return getList().filter { it.title.contains(realQuery, true) }.toList()
.asObservableSuccess()
.map { track }
}
} }
fun findLibManga(track: Track): Observable<Track?> { suspend fun addLibManga(track: Track): Track {
return authClient.newCall(GET(url = listEntryUrl(track.media_id))) authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track))).await()
.asObservable() return track
.map {response -> }
var libTrack: Track? = null
suspend fun updateLibManga(track: Track): Track {
authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track))).await()
return track
}
suspend fun findLibManga(track: Track): Track? {
return withContext(Dispatchers.IO) {
val response = authClient.newCall(GET(url = listEntryUrl(track.media_id))).await()
var remoteTrack: Track? = null
response.use { response.use {
if (it.priorResponse?.isRedirect != true) { if (it.priorResponse?.isRedirect != true) {
val trackForm = Jsoup.parse(it.consumeBody()) val trackForm = Jsoup.parse(it.consumeBody())
libTrack = Track.create(TrackManager.MYANIMELIST).apply { remoteTrack = Track.create(TrackManager.MYANIMELIST).apply {
last_chapter_read = trackForm.select("#add_manga_num_read_chapters").`val`().toInt() last_chapter_read =
trackForm.select("#add_manga_num_read_chapters").`val`().toInt()
total_chapters = trackForm.select("#totalChap").text().toInt() total_chapters = trackForm.select("#totalChap").text().toInt()
status = trackForm.select("#add_manga_status > option[selected]").`val`().toInt() status =
score = trackForm.select("#add_manga_score > option[selected]").`val`().toFloatOrNull() ?: 0f 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 {
return withContext(Dispatchers.IO) {
val csrf = getSessionInfo() 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())
@ -125,23 +122,22 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
.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>> { private suspend fun getList(): List<TrackSearch> {
return getListUrl() val results = getListXml(getListUrl()).select("manga")
.flatMap { url ->
getListXml(url) return results.map {
}
.flatMap { doc ->
Observable.from(doc.select("manga"))
}
.map {
TrackSearch.create(TrackManager.MYANIMELIST).apply { TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("manga_title")!! title = it.selectText("manga_title")!!
media_id = it.selectInt("manga_mangadb_id") media_id = it.selectInt("manga_mangadb_id")
@ -155,10 +151,11 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
.toList() .toList()
} }
private fun getListUrl(): Observable<String> { private suspend fun getListUrl(): String {
return authClient.newCall(POST(url = exportListUrl(), body = exportPostBody())) return withContext(Dispatchers.IO) {
.asObservable() val response =
.map {response -> authClient.newCall(POST(url = exportListUrl(), body = exportPostBody())).execute()
baseUrl + Jsoup.parse(response.consumeBody()) baseUrl + Jsoup.parse(response.consumeBody())
.select("div.goodresult") .select("div.goodresult")
.select("a") .select("a")
@ -166,32 +163,9 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
} }
} }
private fun getListXml(url: String): Observable<Document> { private suspend fun getListXml(url: String): Document {
return authClient.newCall(GET(url)) val response = authClient.newCall(GET(url)).await()
.asObservable() return Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
.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()
}
}
} }
companion object { companion object {
@ -233,7 +207,7 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
.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()
@ -266,12 +240,14 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
.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")
@ -287,7 +263,8 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
.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()!!

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 {
scope.launch {
myanimelist.ensureLoggedIn() 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,7 +47,9 @@ 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 {

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
}.doOnError { } catch (e: java.lang.Exception) {
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,23 +14,25 @@ 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)
suspend fun addLibManga(track: Track, user_id: String): Track {
return withContext(Dispatchers.IO) {
val payload = jsonObject( val payload = jsonObject(
"user_rate" to jsonObject( "user_rate" to jsonObject(
"user_id" to user_id, "user_id" to user_id,
@ -46,16 +48,13 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
.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()
.map {
track 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)
@ -65,17 +64,17 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
.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() val responseBody = netResponse.body?.string().orEmpty()
if (responseBody.isEmpty()) { if (responseBody.isEmpty()) {
throw Exception("Null Response") throw Exception("Null Response")
} }
val response = parser.parse(responseBody).array val response = JsonParser.parseString(responseBody).array
response.map { jsonToSearch(it.obj) }
}
response.map { jsonToSearch(it.obj) }
}
} }
private fun jsonToSearch(obj: JsonObject): TrackSearch { private fun jsonToSearch(obj: JsonObject): TrackSearch {
@ -104,7 +103,8 @@ 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? {
return withContext(Dispatchers.IO) {
val url = Uri.parse("$apiUrl/v2/user_rates").buildUpon() 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())
@ -122,20 +122,18 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
.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()
if (responseBody.isEmpty()) {
throw Exception("Null Response") throw Exception("Null Response")
} }
val response = parser.parse(responseBody).array val response = JsonParser.parseString(requestResponseBody).array
if (response.size() > 1) { if (response.size() > 1) {
throw Exception("Too much mangas in response") throw Exception("Too much mangas in response")
} }
@ -145,15 +143,15 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
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,7 +160,8 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
} }
} }
private fun accessTokenRequest(code: String) = POST(oauthUrl, private fun accessTokenRequest(code: String) = POST(
oauthUrl,
body = FormBody.Builder() body = FormBody.Builder()
.add("grant_type", "authorization_code") .add("grant_type", "authorization_code")
.add("client_id", clientId) .add("client_id", clientId)
@ -172,10 +171,11 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
.build() .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"
@ -196,15 +196,14 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter
.appendQueryParameter("response_type", "code") .appendQueryParameter("response_type", "code")
.build() .build()
fun refreshTokenRequest(token: String) = POST(
fun refreshTokenRequest(token: String) = POST(oauthUrl, oauthUrl,
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

@ -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
@ -231,9 +236,11 @@ class ReaderPresenter(
.andThen(Observable.fromCallable { .andThen(Observable.fromCallable {
val chapterPos = chapterList.indexOf(chapter) val chapterPos = chapterList.indexOf(chapter)
ViewerChapters(chapter, ViewerChapters(
chapter,
chapterList.getOrNull(chapterPos - 1), chapterList.getOrNull(chapterPos - 1),
chapterList.getOrNull(chapterPos + 1)) chapterList.getOrNull(chapterPos + 1)
)
}) })
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.doOnNext { newChapters -> .doOnNext { newChapters ->
@ -464,9 +471,11 @@ 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) }
@ -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 trackList = db.getTracks(manga).executeAsBlocking()
trackList.map { track ->
val service = trackManager.getService(track.sync_id) val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged && chapterRead > track.last_chapter_read) { 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()
} }
/** /**
@ -629,5 +635,4 @@ class ReaderPresenter(
.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) {
scope.launch {
trackManager.aniList.login(matchResult.groups[1]!!.value) trackManager.aniList.login(matchResult.groups[1]!!.value)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
returnToSettings() 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) {
scope.launch {
trackManager.bangumi.login(code) trackManager.bangumi.login(code)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
returnToSettings() 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) {
scope.launch {
trackManager.shikimori.login(code) trackManager.shikimori.login(code)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
returnToSettings() 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,14 +13,18 @@ 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(
private val usernameLabel: String? = null,
bundle: Bundle? = null
) :
DialogController(bundle) { DialogController(bundle) {
var v: View? = null var v: View? = null
@ -28,6 +32,8 @@ abstract class LoginDialogPreference(private val usernameLabel: String? = null,
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,8 +7,7 @@ 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
@ -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 {
errorResult(this@apply)
}
} catch (error: Exception) {
errorResult(this@apply)
error.message?.let { context.toast(it) }
}
}
}
}
fun errorResult(view: View?) {
v?.apply {
login.progress = -1 login.progress = -1
login.setText(R.string.unknown_error) login.setText(R.string.unknown_error)
error.message?.let { context.toast(it) }
})
} }
} }
@ -70,5 +78,4 @@ class TrackLoginDialog(usernameLabel: String? = null, bundle: Bundle? = null) :
interface Listener { interface Listener {
fun trackDialogClosed(service: TrackService) fun trackDialogClosed(service: TrackService)
} }
} }