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"
implementation("com.squareup.retrofit2:retrofit:$retrofit_version")
implementation("com.squareup.retrofit2:converter-gson:$retrofit_version")
implementation("com.squareup.retrofit2:adapter-rxjava:$retrofit_version")
// JSON
implementation("com.google.code.gson:gson:2.8.6")

View File

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

View File

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

View File

@ -3,11 +3,11 @@ package eu.kanade.tachiyomi.data.track
import android.content.Context
import eu.kanade.tachiyomi.data.track.anilist.Anilist
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.bangumi.Bangumi
class TrackManager(private val context: Context) {
class TrackManager(context: Context) {
companion object {
const val MYANIMELIST = 1
@ -17,7 +17,7 @@ class TrackManager(private val context: Context) {
const val BANGUMI = 5
}
val myAnimeList = Myanimelist(context, MYANIMELIST)
val myAnimeList = MyAnimeList(context, MYANIMELIST)
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.network.NetworkHelper
import okhttp3.OkHttpClient
import rx.Completable
import rx.Observable
import uy.kohesive.injekt.injectLazy
abstract class TrackService(val id: Int) {
@ -39,17 +37,15 @@ abstract class TrackService(val id: Int) {
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 fun login(username: String, password: String): Completable
abstract suspend fun login(username: String, password: String): Boolean
@CallSuper
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.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable
import rx.Observable
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
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"
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 getStatusList(): List<Int> {
return listOf(READING, PLANNING, COMPLETED, REPEATING, PAUSED, DROPPED)
}
override fun getStatusList() = listOf(READING, PLANNING, COMPLETED, REPEATING, PAUSED, DROPPED)
override fun getStatus(status: Int): String = with(context) {
when (status) {
@ -95,13 +74,13 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
// 100 point
POINT_100 -> index.toFloat()
// 5 stars
POINT_5 -> when {
index == 0 -> 0f
POINT_5 -> when (index) {
0 -> 0f
else -> index * 20f - 10f
}
// Smiley
POINT_3 -> when {
index == 0 -> 0f
POINT_3 -> when (index) {
0 -> 0f
else -> index * 25f + 10f
}
// 10 point decimal
@ -114,8 +93,8 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
val score = track.score
return when (scorePreference.getOrDefault()) {
POINT_5 -> when {
score == 0f -> "0 ★"
POINT_5 -> when (score) {
0f -> "0 ★"
else -> "${((score + 10) / 20).toInt()}"
}
POINT_3 -> when {
@ -128,68 +107,62 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
}
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track)
}
override fun update(track: Track): Observable<Track> {
override suspend fun update(track: Track): Track {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED
}
// If user was using API v1 fetch library_id
if (track.library_id == null || track.library_id!! == 0L){
return api.findLibManga(track, getUsername().toInt()).flatMap {
if (it == null) {
throw Exception("$track not found on user library")
}
track.library_id = it.library_id
api.updateLibManga(track)
}
if (track.library_id == null || track.library_id!! == 0L) {
val libManga = api.findLibManga(track, getUsername().toInt())
?: throw Exception("$track not found on user library")
track.library_id = libManga.library_id
}
return api.updateLibManga(track)
return api.updateLibraryManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername().toInt())
.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 suspend fun bind(track: Track): Track {
val remoteTrack = api.findLibManga(track, getUsername().toInt())
return 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
api.addLibManga(track)
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query)
override suspend fun search(query: String) = api.search(query)
override suspend fun refresh(track: Track): Track {
val remoteTrack = api.getLibManga(track, getUsername().toInt())
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
return track
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track, getUsername().toInt())
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
}
override suspend fun login(username: String, password: String) = login(password)
override fun login(username: String, password: String) = login(password)
fun login(token: String): Completable {
suspend fun login(token: String): Boolean {
val oauth = api.createOAuth(token)
interceptor.setAuth(oauth)
return api.getCurrentUser().map { (username, scoreType) ->
scorePreference.set(scoreType)
saveCredentials(username.toString(), oauth.access_token)
}.doOnError{
logout()
}.toCompletable()
return try {
val currentUser = api.getCurrentUser()
scorePreference.set(currentUser.second)
saveCredentials(currentUser.first.toString(), oauth.access_token)
true
} catch (e: Exception) {
Timber.e(e)
logout()
false
}
}
override fun logout() {
@ -206,9 +179,29 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
return try {
gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
} catch (e: Exception) {
Timber.e(e)
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 eu.kanade.tachiyomi.data.database.models.Track
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.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import rx.Observable
import okhttp3.Response
import java.util.Calendar
import java.util.concurrent.TimeUnit
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()
fun addLibManga(track: Track): Observable<Track> {
val query = """
suspend fun addLibManga(track: Track): Track {
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) {
|SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) {
| id
@ -37,36 +221,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|}
|}
|""".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> {
val query = """
fun updateInLibraryQuery() = """
|mutation UpdateManga(${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}score: Int) {
|SaveMediaListEntry (id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, scoreRaw: ${'$'}score) {
|id
@ -75,30 +231,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|}
|}
|""".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>> {
val query = """
fun searchQuery() = """
|query Search(${'$'}query: String) {
|Page (perPage: 50) {
|media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
@ -122,37 +256,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|}
|}
|""".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 findLibManga(track: Track, userid: Int): Observable<Track?> {
val query = """
fun findLibraryMangaQuery() = """
|query (${'$'}id: Int!, ${'$'}manga_id: Int!) {
|Page {
|mediaList(userId: ${'$'}id, type: MANGA, mediaId: ${'$'}manga_id) {
@ -182,47 +287,8 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|}
|}
|""".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 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 = """
fun currentUserQuery() = """
|query User {
|Viewer {
|id
@ -232,62 +298,5 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
|}
|}
|""".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
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.

View File

@ -9,6 +9,15 @@ import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
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(
val media_id: Int,
val title_romaji: String,
@ -56,7 +65,7 @@ data class ALUserManga(
total_chapters = manga.total_chapters
}
fun toTrackStatus() = when (list_status) {
private fun toTrackStatus() = when (list_status) {
"CURRENT" -> Anilist.READING
"COMPLETED" -> Anilist.COMPLETED
"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.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable
import rx.Observable
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
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()
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track)
}
override fun update(track: Track): Observable<Track> {
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)
}
override fun bind(track: Track): Observable<Track> {
return api.statusLibManga(track)
.flatMap {
api.findLibManga(track).flatMap { remoteTrack ->
if (remoteTrack != null && it != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
track.status = remoteTrack.status
track.last_chapter_read = remoteTrack.last_chapter_read
refresh(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
update(track)
}
}
}
override suspend fun bind(track: Track): Track {
val statusTrack = api.statusLibManga(track)
val remoteTrack = api.findLibManga(track)
if (statusTrack != null && remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
track.status = remoteTrack.status
track.last_chapter_read = remoteTrack.last_chapter_read
refresh(track)
} else {
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
api.addLibManga(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)
}
override fun refresh(track: Track): Observable<Track> {
return api.statusLibManga(track)
.flatMap {
track.copyPersonalFrom(it!!)
api.findLibManga(track)
.map { remoteTrack ->
if (remoteTrack != null) {
track.total_chapters = remoteTrack.total_chapters
track.status = remoteTrack.status
}
track
}
}
override suspend fun refresh(track: Track): Track {
val statusTrack = api.statusLibManga(track)
track.copyPersonalFrom(statusTrack!!)
val remoteTrack = api.findLibManga(track)
if(remoteTrack != null){
track.total_chapters = remoteTrack.total_chapters
track.status = remoteTrack.status
}
return track
}
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 {
return api.accessToken(code).map { oauth: OAuth? ->
suspend fun login(code: String): Boolean {
try {
val oauth = api.accessToken(code)
interceptor.newAuth(oauth)
if (oauth != null) {
saveCredentials(oauth.user_id.toString(), oauth.access_token)
}
}.doOnError {
saveCredentials(oauth.user_id.toString(), oauth.access_token)
return true
} catch (e: Exception) {
Timber.e(e)
logout()
}.toCompletable()
}
return false
}
fun saveToken(oauth: OAuth?) {
@ -128,15 +119,15 @@ class Bangumi(private val context: Context, id: Int) : TrackService(id) {
override fun logout() {
super.logout()
preferences.trackToken(this).set(null)
interceptor.newAuth(null)
interceptor.clearOauth()
}
companion object {
const val READING = 3
const val PLANNING = 1
const val COMPLETED = 2
const val READING = 3
const val ON_HOLD = 4
const val DROPPED = 5
const val PLANNING = 1
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0

View File

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

View File

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

View File

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

View File

@ -1,6 +1,11 @@
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.JsonObject
import eu.kanade.tachiyomi.data.database.models.Track
@ -9,240 +14,228 @@ import eu.kanade.tachiyomi.network.POST
import okhttp3.FormBody
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.*
import rx.Observable
import retrofit2.http.Body
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) {
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
private val rest = Retrofit.Builder()
.baseUrl(baseUrl)
.client(authClient)
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(KitsuApi.Rest::class.java)
.baseUrl(baseUrl)
.client(authClient)
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().serializeNulls().create()))
.build()
.create(KitsuApi.Rest::class.java)
private val searchRest = Retrofit.Builder()
.baseUrl(algoliaKeyUrl)
.client(authClient)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(KitsuApi.SearchKeyRest::class.java)
.baseUrl(algoliaKeyUrl)
.client(authClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(KitsuApi.SearchKeyRest::class.java)
private val algoliaRest = Retrofit.Builder()
.baseUrl(algoliaUrl)
.baseUrl(algoliaUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(KitsuApi.AgoliaSearchRest::class.java)
suspend fun addLibManga(track: Track, userId: String): Track {
// @formatter:off
val data = jsonObject(
"type" to "libraryEntries",
"attributes" to jsonObject(
"status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read
),
"relationships" to jsonObject(
"user" to jsonObject(
"data" to jsonObject(
"id" to userId,
"type" to "users"
)
),
"media" to jsonObject(
"data" to jsonObject(
"id" to track.media_id,
"type" to "manga"
)
)
)
)
val json = rest.addLibManga(jsonObject("data" to data))
track.media_id = json["data"]["id"].int
return track
}
suspend fun updateLibManga(track: Track): Track {
// @formatter:off
val data = jsonObject(
"type" to "libraryEntries",
"id" to track.media_id,
"attributes" to jsonObject(
"status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read,
"ratingTwenty" to track.toKitsuScore()
)
)
// @formatter:on
rest.updateLibManga(track.media_id, jsonObject("data" to data))
return track
}
suspend fun search(query: String): List<TrackSearch> {
val key = searchRest.getKey()["media"].asJsonObject["key"].string
return algoliaSearch(key, query)
}
private suspend fun algoliaSearch(key: String, query: String): List<TrackSearch> {
val jsonObject = jsonObject("params" to "query=$query$algoliaFilter")
val json = algoliaRest.getSearchQuery(algoliaAppId, key, jsonObject)
val data = json["hits"].array
return data.map { KitsuSearchManga(it.obj) }
.filter { it.subType != "novel" }
.map { it.toTrack() }
}
suspend fun findLibManga(track: Track, userId: String): Track? {
val json = rest.findLibManga(track.media_id, userId)
val data = json["data"].array
return if (data.size() > 0) {
val manga = json["included"].array[0].obj
KitsuLibManga(data[0].obj, manga).toTrack()
} else {
null
}
}
suspend fun getLibManga(track: Track): Track {
val json = rest.getLibManga(track.media_id)
val data = json["data"].array
if (data.size() > 0) {
val manga = json["included"].array[0].obj
return KitsuLibManga(data[0].obj, manga).toTrack()
} else {
throw Exception("Could not find manga")
}
}
suspend fun login(username: String, password: String): OAuth {
return Retrofit.Builder()
.baseUrl(loginUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(KitsuApi.AgoliaSearchRest::class.java)
fun addLibManga(track: Track, userId: String): Observable<Track> {
return Observable.defer {
// @formatter:off
val data = jsonObject(
"type" to "libraryEntries",
"attributes" to jsonObject(
"status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read
),
"relationships" to jsonObject(
"user" to jsonObject(
"data" to jsonObject(
"id" to userId,
"type" to "users"
)
),
"media" to jsonObject(
"data" to jsonObject(
"id" to track.media_id,
"type" to "manga"
)
)
)
)
rest.addLibManga(jsonObject("data" to data))
.map { json ->
track.media_id = json["data"]["id"].int
track
}
}
.create(KitsuApi.LoginRest::class.java)
.requestAccessToken(username, password)
}
fun updateLibManga(track: Track): Observable<Track> {
return Observable.defer {
// @formatter:off
val data = jsonObject(
"type" to "libraryEntries",
"id" to track.media_id,
"attributes" to jsonObject(
"status" to track.toKitsuStatus(),
"progress" to track.last_chapter_read,
"ratingTwenty" to track.toKitsuScore()
)
)
// @formatter:on
rest.updateLibManga(track.media_id, jsonObject("data" to data))
.map { track }
}
}
fun search(query: String): Observable<List<TrackSearch>> {
return searchRest
.getKey().map { json ->
json["media"].asJsonObject["key"].string
}.flatMap { key ->
algoliaSearch(key, query)
}
}
private fun algoliaSearch(key: String, query: String): Observable<List<TrackSearch>> {
val jsonObject = jsonObject("params" to "query=$query$algoliaFilter")
return algoliaRest
.getSearchQuery(algoliaAppId, key, jsonObject)
.map { json ->
val data = json["hits"].array
data.map { KitsuSearchManga(it.obj) }
.filter { it.subType != "novel" }
.map { it.toTrack() }
}
}
fun findLibManga(track: Track, userId: String): Observable<Track?> {
return rest.findLibManga(track.media_id, userId)
.map { json ->
val data = json["data"].array
if (data.size() > 0) {
val manga = json["included"].array[0].obj
KitsuLibManga(data[0].obj, manga).toTrack()
} else {
null
}
}
}
fun getLibManga(track: Track): Observable<Track> {
return rest.getLibManga(track.media_id)
.map { json ->
val data = json["data"].array
if (data.size() > 0) {
val manga = json["included"].array[0].obj
KitsuLibManga(data[0].obj, manga).toTrack()
} else {
throw Exception("Could not find manga")
}
}
}
fun login(username: String, password: String): Observable<OAuth> {
return Retrofit.Builder()
.baseUrl(loginUrl)
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.build()
.create(KitsuApi.LoginRest::class.java)
.requestAccessToken(username, password)
}
fun getCurrentUser(): Observable<String> {
return rest.getCurrentUser().map { it["data"].array[0]["id"].string }
suspend fun getCurrentUser(): String {
val currentUser = rest.getCurrentUser()
return currentUser["data"].array[0]["id"].string
}
private interface Rest {
@Headers("Content-Type: application/vnd.api+json")
@POST("library-entries")
fun addLibManga(
@Body data: JsonObject
): Observable<JsonObject>
suspend fun addLibManga(
@Body data: JsonObject
): JsonObject
@Headers("Content-Type: application/vnd.api+json")
@PATCH("library-entries/{id}")
fun updateLibManga(
@Path("id") remoteId: Int,
@Body data: JsonObject
): Observable<JsonObject>
suspend fun updateLibManga(
@Path("id") remoteId: Int,
@Body data: JsonObject
): JsonObject
@GET("library-entries")
fun findLibManga(
@Query("filter[manga_id]", encoded = true) remoteId: Int,
@Query("filter[user_id]", encoded = true) userId: String,
@Query("include") includes: String = "manga"
): Observable<JsonObject>
suspend fun findLibManga(
@Query("filter[manga_id]", encoded = true) remoteId: Int,
@Query("filter[user_id]", encoded = true) userId: String,
@Query("include") includes: String = "manga"
): JsonObject
@GET("library-entries")
fun getLibManga(
@Query("filter[id]", encoded = true) remoteId: Int,
@Query("include") includes: String = "manga"
): Observable<JsonObject>
suspend fun getLibManga(
@Query("filter[id]", encoded = true) remoteId: Int,
@Query("include") includes: String = "manga"
): JsonObject
@GET("users")
fun getCurrentUser(
@Query("filter[self]", encoded = true) self: Boolean = true
): Observable<JsonObject>
suspend fun getCurrentUser(
@Query("filter[self]", encoded = true) self: Boolean = true
): JsonObject
}
private interface SearchKeyRest {
@GET("media/")
fun getKey(): Observable<JsonObject>
suspend fun getKey(): JsonObject
}
private interface AgoliaSearchRest {
@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 {
@FormUrlEncoded
@POST("oauth/token")
fun requestAccessToken(
@Field("username") username: String,
@Field("password") password: String,
@Field("grant_type") grantType: String = "password",
@Field("client_id") client_id: String = clientId,
@Field("client_secret") client_secret: String = clientSecret
): Observable<OAuth>
suspend fun requestAccessToken(
@Field("username") username: String,
@Field("password") password: String,
@Field("grant_type") grantType: String = "password",
@Field("client_id") client_id: String = clientId,
@Field("client_secret") client_secret: String = clientSecret
): OAuth
}
companion object {
private const val clientId = "dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
private const val clientSecret = "54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
private const val clientId =
"dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd"
private const val clientSecret =
"54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151"
private const val baseUrl = "https://kitsu.io/api/edge/"
private const val loginUrl = "https://kitsu.io/api/"
private const val baseMangaUrl = "https://kitsu.io/manga/"
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 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 {
return baseMangaUrl + remoteId
}
fun refreshTokenRequest(token: String) = POST("${loginUrl}oauth/token",
body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("refresh_token", token)
.build())
fun refreshTokenRequest(token: String) = POST(
"${loginUrl}oauth/token",
body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", clientId)
.add("client_secret", clientSecret)
.add("refresh_token", token)
.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.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import rx.Completable
import rx.Observable
import timber.log.Timber
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"
}
class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
private val interceptor by lazy { MyAnimeListInterceptor(this) }
private val api by lazy { MyAnimeListApi(client, interceptor) }
override val name: String
get() = "MyAnimeList"
override val name = "MyAnimeList"
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()
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track)
}
override fun update(track: Track): Observable<Track> {
override suspend fun update(track: Track): Track {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED
}
@ -74,45 +52,46 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
return api.updateLibManga(track)
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track)
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
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 suspend fun bind(track: Track): Track {
val remoteTrack = api.findLibManga(track)
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
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)
}
return track
}
override fun search(query: String): Observable<List<TrackSearch>> {
override suspend fun search(query: String): List<TrackSearch> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.getLibManga(track)
.map { remoteTrack ->
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
track
}
override suspend fun refresh(track: Track): Track {
val remoteTrack = api.getLibManga(track)
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
return track
}
override fun login(username: String, password: String): Completable {
override suspend fun login(username: String, password: String): Boolean {
logout()
return Observable.fromCallable { api.login(username, password) }
.doOnNext { csrf -> saveCSRF(csrf) }
.doOnNext { saveCredentials(username, password) }
.doOnError { logout() }
.toCompletable()
return try {
val csrf = api.login(username, password)
saveCSRF(csrf)
saveCredentials(username, password)
true
} catch (e: Exception) {
Timber.e(e)
logout()
false
}
}
fun refreshLogin() {
private suspend fun refreshLogin() {
val username = getUsername()
val password = getPassword()
logout()
@ -122,13 +101,14 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
saveCSRF(csrf)
saveCredentials(username, password)
} catch (e: Exception) {
Timber.e(e)
logout()
throw e
}
}
// Attempt to login again if cookies have been cleared but credentials are still filled
fun ensureLoggedIn() {
suspend fun ensureLoggedIn() {
if (isAuthorized) return
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()!!)
}
val isAuthorized: Boolean
get() = super.isLogged &&
getCSRF().isNotEmpty() &&
checkCookies()
private val isAuthorized = super.isLogged && getCSRF().isNotEmpty() && checkCookies()
fun getCSRF(): String = preferences.trackToken(this).getOrDefault()
@ -161,4 +138,18 @@ class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
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.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservable
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.consumeBody
import eu.kanade.tachiyomi.network.consumeXmlBody
import eu.kanade.tachiyomi.util.selectInt
import eu.kanade.tachiyomi.util.selectText
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.json.JSONObject
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
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) {
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun search(query: String): Observable<List<TrackSearch>> {
return if (query.startsWith(PREFIX_MY)) {
val realQuery = query.removePrefix(PREFIX_MY)
getList()
.flatMap { Observable.from(it) }
.filter { it.title.contains(realQuery, true) }
.toList()
} else {
client.newCall(GET(searchUrl(query)))
.asObservable()
.flatMap { response ->
Observable.from(Jsoup.parse(response.consumeBody())
.select("div.js-categories-seasonal.js-block-list.list")
.select("table").select("tbody")
.select("tr").drop(1))
}
.filter { row ->
row.select(TD)[2].text() != "Novel"
}
suspend fun search(query: String): List<TrackSearch> {
return withContext(Dispatchers.IO) {
if (query.startsWith(PREFIX_MY)) {
queryUsersList(query)
} else {
val realQuery = query.take(100)
val response = client.newCall(GET(searchUrl(realQuery))).await()
val matches = Jsoup.parse(response.consumeBody())
.select("div.js-categories-seasonal.js-block-list.list")
.select("table").select("tbody")
.select("tr").drop(1)
matches.filter { row -> row.select(TD)[2].text() != "Novel" }
.map { row ->
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = row.searchTitle()
@ -64,136 +55,119 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
}
}
.toList()
}
}
}
fun addLibManga(track: Track): Observable<Track> {
return Observable.defer {
authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track)))
.asObservableSuccess()
.map { track }
}
private suspend fun queryUsersList(query: String): List<TrackSearch> {
val realQuery = query.removePrefix(PREFIX_MY).take(100)
return getList().filter { it.title.contains(realQuery, true) }.toList()
}
fun updateLibManga(track: Track): Observable<Track> {
return Observable.defer {
authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track)))
.asObservableSuccess()
.map { track }
}
suspend fun addLibManga(track: Track): Track {
authClient.newCall(POST(url = addUrl(), body = mangaPostPayload(track))).await()
return track
}
fun findLibManga(track: Track): Observable<Track?> {
return authClient.newCall(GET(url = listEntryUrl(track.media_id)))
.asObservable()
.map {response ->
var libTrack: Track? = null
response.use {
if (it.priorResponse?.isRedirect != true) {
val trackForm = Jsoup.parse(it.consumeBody())
suspend fun updateLibManga(track: Track): Track {
authClient.newCall(POST(url = updateUrl(), body = mangaPostPayload(track))).await()
return track
}
libTrack = Track.create(TrackManager.MYANIMELIST).apply {
last_chapter_read = trackForm.select("#add_manga_num_read_chapters").`val`().toInt()
total_chapters = trackForm.select("#totalChap").text().toInt()
status = trackForm.select("#add_manga_status > option[selected]").`val`().toInt()
score = trackForm.select("#add_manga_score > option[selected]").`val`().toFloatOrNull() ?: 0f
}
}
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 {
if (it.priorResponse?.isRedirect != true) {
val trackForm = Jsoup.parse(it.consumeBody())
remoteTrack = Track.create(TrackManager.MYANIMELIST).apply {
last_chapter_read =
trackForm.select("#add_manga_num_read_chapters").`val`().toInt()
total_chapters = trackForm.select("#totalChap").text().toInt()
status =
trackForm.select("#add_manga_status > option[selected]").`val`().toInt()
score = trackForm.select("#add_manga_score > option[selected]").`val`()
.toFloatOrNull() ?: 0f
}
libTrack
}
}
remoteTrack
}
}
fun getLibManga(track: Track): Observable<Track> {
return findLibManga(track)
.map { it ?: throw Exception("Could not find manga") }
suspend fun getLibManga(track: Track): Track {
val result = findLibManga(track)
if (result == null) {
throw Exception("Could not find manga")
} else {
return result
}
}
fun login(username: String, password: String): String {
val csrf = getSessionInfo()
login(username, password, csrf)
return csrf
suspend fun login(username: String, password: String): String {
return withContext(Dispatchers.IO) {
val csrf = getSessionInfo()
login(username, password, csrf)
csrf
}
}
private fun getSessionInfo(): String {
private suspend fun getSessionInfo(): String {
val response = client.newCall(GET(loginUrl())).execute()
return Jsoup.parse(response.consumeBody())
.select("meta[name=csrf_token]")
.attr("content")
.select("meta[name=csrf_token]")
.attr("content")
}
private fun login(username: String, password: String, csrf: String) {
val response = client.newCall(POST(url = loginUrl(), body = loginPostBody(username, password, csrf))).execute()
private suspend fun login(username: String, password: String, csrf: String) {
withContext(Dispatchers.IO) {
val response =
client.newCall(POST(loginUrl(), body = loginPostBody(username, password, csrf)))
.execute()
response.use {
if (response.priorResponse?.code != 302) throw Exception("Authentication error")
}
}
private fun getList(): Observable<List<TrackSearch>> {
return getListUrl()
.flatMap { url ->
getListXml(url)
}
.flatMap { doc ->
Observable.from(doc.select("manga"))
}
.map {
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("manga_title")!!
media_id = it.selectInt("manga_mangadb_id")
last_chapter_read = it.selectInt("my_read_chapters")
status = getStatus(it.selectText("my_status")!!)
score = it.selectInt("my_score").toFloat()
total_chapters = it.selectInt("manga_chapters")
tracking_url = mangaUrl(media_id)
}
}
.toList()
}
private fun getListUrl(): Observable<String> {
return authClient.newCall(POST(url = exportListUrl(), body = exportPostBody()))
.asObservable()
.map {response ->
baseUrl + Jsoup.parse(response.consumeBody())
.select("div.goodresult")
.select("a")
.attr("href")
}
}
private fun getListXml(url: String): Observable<Document> {
return authClient.newCall(GET(url))
.asObservable()
.map { response ->
Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
}
}
private fun Response.consumeBody(): String? {
use {
if (it.code != 200) throw Exception("HTTP error ${it.code}")
return it.body?.string()
}
}
private fun Response.consumeXmlBody(): String? {
use { res ->
if (res.code != 200) throw Exception("Export list error")
BufferedReader(InputStreamReader(GZIPInputStream(res.body?.source()?.inputStream()))).use { reader ->
val sb = StringBuilder()
reader.forEachLine { line ->
sb.append(line)
}
return sb.toString()
response.use {
if (response.priorResponse?.code != 302) throw Exception("Authentication error")
}
}
}
private suspend fun getList(): List<TrackSearch> {
val results = getListXml(getListUrl()).select("manga")
return results.map {
TrackSearch.create(TrackManager.MYANIMELIST).apply {
title = it.selectText("manga_title")!!
media_id = it.selectInt("manga_mangadb_id")
last_chapter_read = it.selectInt("my_read_chapters")
status = getStatus(it.selectText("my_status")!!)
score = it.selectInt("my_score").toFloat()
total_chapters = it.selectInt("manga_chapters")
tracking_url = mangaUrl(media_id)
}
}
.toList()
}
private suspend fun getListUrl(): String {
return withContext(Dispatchers.IO) {
val response =
authClient.newCall(POST(url = exportListUrl(), body = exportPostBody())).execute()
baseUrl + Jsoup.parse(response.consumeBody())
.select("div.goodresult")
.select("a")
.attr("href")
}
}
private suspend fun getListXml(url: String): Document {
val response = authClient.newCall(GET(url)).await()
return Jsoup.parse(response.consumeXmlBody(), "", Parser.xmlParser())
}
companion object {
const val CSRF = "csrf_token"
@ -206,88 +180,91 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
private fun mangaUrl(remoteId: Int) = baseMangaUrl + remoteId
private fun loginUrl() = Uri.parse(baseUrl).buildUpon()
.appendPath("login.php")
.toString()
.appendPath("login.php")
.toString()
private fun searchUrl(query: String): String {
val col = "c[]"
return Uri.parse(baseUrl).buildUpon()
.appendPath("manga.php")
.appendQueryParameter("q", query)
.appendQueryParameter(col, "a")
.appendQueryParameter(col, "b")
.appendQueryParameter(col, "c")
.appendQueryParameter(col, "d")
.appendQueryParameter(col, "e")
.appendQueryParameter(col, "g")
.toString()
.appendPath("manga.php")
.appendQueryParameter("q", query)
.appendQueryParameter(col, "a")
.appendQueryParameter(col, "b")
.appendQueryParameter(col, "c")
.appendQueryParameter(col, "d")
.appendQueryParameter(col, "e")
.appendQueryParameter(col, "g")
.toString()
}
private fun exportListUrl() = Uri.parse(baseUrl).buildUpon()
.appendPath("panel.php")
.appendQueryParameter("go", "export")
.toString()
.appendPath("panel.php")
.appendQueryParameter("go", "export")
.toString()
private fun updateUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath("edit.json")
.toString()
.appendPath("edit.json")
.toString()
private fun addUrl() = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath( "add.json")
.toString()
.appendPath("add.json")
.toString()
private fun listEntryUrl(mediaId: Int) = Uri.parse(baseModifyListUrl).buildUpon()
.appendPath(mediaId.toString())
.appendPath("edit")
.toString()
.appendPath(mediaId.toString())
.appendPath("edit")
.toString()
private fun loginPostBody(username: String, password: String, csrf: String): RequestBody {
return FormBody.Builder()
.add("user_name", username)
.add("password", password)
.add("cookie", "1")
.add("sublogin", "Login")
.add("submit", "1")
.add(CSRF, csrf)
.build()
.add("user_name", username)
.add("password", password)
.add("cookie", "1")
.add("sublogin", "Login")
.add("submit", "1")
.add(CSRF, csrf)
.build()
}
private fun exportPostBody(): RequestBody {
return FormBody.Builder()
.add("type", "2")
.add("subexport", "Export My List")
.build()
.add("type", "2")
.add("subexport", "Export My List")
.build()
}
private fun mangaPostPayload(track: Track): RequestBody {
val body = JSONObject()
.put("manga_id", track.media_id)
.put("status", track.status)
.put("score", track.score)
.put("num_read_chapters", track.last_chapter_read)
.put("manga_id", track.media_id)
.put("status", track.status)
.put("score", track.score)
.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.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")
.attr("data-src")
.split("\\?")[0]
.replace("/r/50x70/", "/")
.attr("data-src")
.split("\\?")[0]
.replace("/r/50x70/", "/")
private fun Element.searchMediaId() = select("div.picSurround")
.select("a").attr("id")
.replace("sarea", "")
.toInt()
.select("a").attr("id")
.replace("sarea", "")
.toInt()
private fun Element.searchSummary() = select("div.pt4")
.first()
.ownText()!!
.first()
.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()!!
@ -300,6 +277,6 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI
"Dropped" -> 4
"Plan to Read" -> 6
else -> 1
}
}
}
}

View File

@ -1,5 +1,9 @@
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.Request
import okhttp3.RequestBody
@ -8,20 +12,17 @@ import okhttp3.Response
import okio.Buffer
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 {
myanimelist.ensureLoggedIn()
val request = chain.request()
var response = chain.proceed(updateRequest(request))
if (response.code == 400) {
myanimelist.refreshLogin()
response = chain.proceed(updateRequest(request))
scope.launch {
myanimelist.ensureLoggedIn()
}
val request = chain.request()
return chain.proceed(updateRequest(request))
return response
}
private fun updateRequest(request: Request): Request {
@ -46,13 +47,15 @@ class MyAnimeListInterceptor(private val myanimelist: Myanimelist): Interceptor
private fun updateFormBody(requestBody: RequestBody): 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 {
val jsonString = bodyToString(requestBody)
val newBody = JSONObject(jsonString)
.put(MyAnimeListApi.CSRF, myanimelist.getCSRF())
.put(MyAnimeListApi.CSRF, myanimelist.getCSRF())
return newBody.toString().toRequestBody(requestBody.contentType())
}

View File

@ -1,13 +0,0 @@
package eu.kanade.tachiyomi.data.track.shikimori
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long,
val expires_in: Long,
val refresh_token: String?) {
// Access token lives 1 day
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}

View File

@ -7,74 +7,11 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import rx.Completable
import rx.Observable
import timber.log.Timber
import uy.kohesive.injekt.injectLazy
class Shikimori(private val context: Context, id: Int) : TrackService(id) {
override fun getScoreList(): List<String> {
return IntRange(0, 10).map(Int::toString)
}
override fun displayScore(track: Track): String {
return track.score.toInt().toString()
}
override fun add(track: Track): Observable<Track> {
return api.addLibManga(track, getUsername())
}
override fun update(track: Track): Observable<Track> {
if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
track.status = COMPLETED
}
return api.updateLibManga(track, getUsername())
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername())
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
update(track)
} else {
// Set default fields if it's not found in the list
track.score = DEFAULT_SCORE.toFloat()
track.status = DEFAULT_STATUS
add(track)
}
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername())
.map { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
}
track
}
}
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLANNING = 5
const val REPEATING = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
override val name = "Shikimori"
private val gson: Gson by injectLazy()
@ -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)
if (oauth != null) {
val user = api.getCurrentUser()
saveCredentials(user.toString(), oauth.access_token)
}
}.doOnError {
val user = api.getCurrentUser()
saveCredentials(user.toString(), oauth.access_token)
return true
} catch (e: java.lang.Exception) {
Timber.e(e)
logout()
}.toCompletable()
return false
}
}
fun saveToken(oauth: OAuth?) {
@ -135,4 +118,16 @@ class Shikimori(private val context: Context, id: Int) : TrackService(id) {
preferences.trackToken(this).set(null)
interceptor.newAuth(null)
}
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLANNING = 5
const val REPEATING = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
}

View File

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

View File

@ -22,3 +22,15 @@ fun toTrackStatus(status: String) = when (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
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.updater.devrepo.DevRepoUpdateChecker
import eu.kanade.tachiyomi.data.updater.github.GithubUpdateChecker
import rx.Observable
abstract class UpdateChecker {
companion object {
fun getUpdateChecker(): UpdateChecker {
return if (BuildConfig.DEBUG) {
DevRepoUpdateChecker()
} else {
GithubUpdateChecker()
}
}
fun getUpdateChecker(): UpdateChecker = 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 retrofit2.Retrofit
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import rx.Observable
@ -19,7 +18,6 @@ interface GithubService {
val restAdapter = Retrofit.Builder()
.baseUrl("https://api.github.com")
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
.client(Injekt.get<NetworkHelper>().client)
.build()
@ -28,6 +26,6 @@ interface GithubService {
}
@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 okhttp3.*
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import rx.Observable
import rx.Producer
import rx.Subscription
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.util.concurrent.atomic.AtomicBoolean
import java.util.zip.GZIPInputStream
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@ -94,3 +98,25 @@ fun OkHttpClient.newCallWithProgress(request: Request, listener: ProgressListene
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.manga_details_controller.*
import kotlinx.android.synthetic.main.manga_header_item.*
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
@ -927,10 +928,12 @@ class MangaDetailsController : BaseController,
}
fun trackRefreshError(error: Exception) {
Timber.e(error)
trackingBottomSheet?.onRefreshError(error)
}
fun trackSearchError(error: Exception) {
Timber.e(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 ->
withContext(Dispatchers.IO) {
val trackItem = try {
item.service.refresh(item.track!!).toBlocking().single()
item.service.refresh(item.track!!)
} catch (e: Exception) {
trackError(e)
null
@ -710,7 +710,7 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
fun trackSearch(query: String, service: TrackService) {
launch(Dispatchers.IO) {
val results = try {service.search(query).toBlocking().single() }
val results = try {service.search(query) }
catch (e: Exception) {
withContext(Dispatchers.Main) { controller.trackSearchError(e) }
null }
@ -725,7 +725,7 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
item.manga_id = manga.id!!
launch {
val binding = try { service.bind(item).toBlocking().single() }
val binding = try { service.bind(item) }
catch (e: Exception) {
trackError(e)
null
@ -745,7 +745,7 @@ class MangaDetailsPresenter(private val controller: MangaDetailsController,
private fun updateRemote(track: Track, service: TrackService) {
launch {
val binding = try { service.update(track).toBlocking().single() }
val binding = try { service.update(track) }
catch (e: Exception) {
trackError(e)
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.util.storage.DiskUtil
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.Observable
import rx.Subscription
@ -40,11 +45,11 @@ import java.util.concurrent.TimeUnit
* Presenter used by the activity to perform background operations.
*/
class ReaderPresenter(
private val db: DatabaseHelper = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get()
private val db: DatabaseHelper = Injekt.get(),
private val sourceManager: SourceManager = Injekt.get(),
private val downloadManager: DownloadManager = Injekt.get(),
private val coverCache: CoverCache = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get()
) : BasePresenter<ReaderActivity>() {
/**
@ -87,19 +92,19 @@ class ReaderPresenter(
val dbChapters = db.getChapters(manga).executeAsBlocking()
val selectedChapter = dbChapters.find { it.id == chapterId }
?: error("Requested chapter of id $chapterId not found in chapter list")
?: error("Requested chapter of id $chapterId not found in chapter list")
val chaptersForReader =
if (preferences.skipRead()) {
val list = dbChapters.filter { !it.read }.toMutableList()
val find = list.find { it.id == chapterId }
if (find == null) {
list.add(selectedChapter)
}
list
} else {
dbChapters
if (preferences.skipRead()) {
val list = dbChapters.filter { !it.read }.toMutableList()
val find = list.find { it.id == chapterId }
if (find == null) {
list.add(selectedChapter)
}
list
} else {
dbChapters
}
when (manga.sorting) {
Manga.SORTING_SOURCE -> ChapterLoadBySource().get(chaptersForReader)
@ -170,12 +175,12 @@ class ReaderPresenter(
if (!needsInit()) return
db.getManga(mangaId).asRxObservable()
.first()
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { init(it, initialChapterId) }
.subscribeFirst({ _, _ ->
// Ignore onNext event
}, ReaderActivity::setInitialChapterError)
.first()
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { init(it, initialChapterId) }
.subscribeFirst({ _, _ ->
// Ignore onNext event
}, ReaderActivity::setInitialChapterError)
}
fun init(mangaId: Long, chapterUrl: String) {
@ -207,13 +212,13 @@ class ReaderPresenter(
// Read chapterList from an io thread because it's retrieved lazily and would block main.
activeChapterSubscription?.unsubscribe()
activeChapterSubscription = Observable
.fromCallable { chapterList.first { chapterId == it.chapter.id } }
.flatMap { getLoadObservable(loader!!, it) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ _, _ ->
// Ignore onNext event
}, ReaderActivity::setInitialChapterError)
.fromCallable { chapterList.first { chapterId == it.chapter.id } }
.flatMap { getLoadObservable(loader!!, it) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst({ _, _ ->
// Ignore onNext event
}, ReaderActivity::setInitialChapterError)
}
/**
@ -224,27 +229,29 @@ class ReaderPresenter(
* Callers must also handle the onError event.
*/
private fun getLoadObservable(
loader: ChapterLoader,
chapter: ReaderChapter
loader: ChapterLoader,
chapter: ReaderChapter
): Observable<ViewerChapters> {
return loader.loadChapter(chapter)
.andThen(Observable.fromCallable {
val chapterPos = chapterList.indexOf(chapter)
.andThen(Observable.fromCallable {
val chapterPos = chapterList.indexOf(chapter)
ViewerChapters(chapter,
chapterList.getOrNull(chapterPos - 1),
chapterList.getOrNull(chapterPos + 1))
})
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { newChapters ->
val oldChapters = viewerChaptersRelay.value
ViewerChapters(
chapter,
chapterList.getOrNull(chapterPos - 1),
chapterList.getOrNull(chapterPos + 1)
)
})
.observeOn(AndroidSchedulers.mainThread())
.doOnNext { newChapters ->
val oldChapters = viewerChaptersRelay.value
// Add new references first to avoid unnecessary recycling
newChapters.ref()
oldChapters?.unref()
// Add new references first to avoid unnecessary recycling
newChapters.ref()
oldChapters?.unref()
viewerChaptersRelay.call(newChapters)
}
viewerChaptersRelay.call(newChapters)
}
}
/**
@ -258,10 +265,10 @@ class ReaderPresenter(
activeChapterSubscription?.unsubscribe()
activeChapterSubscription = getLoadObservable(loader, chapter)
.toCompletable()
.onErrorComplete()
.subscribe()
.also(::add)
.toCompletable()
.onErrorComplete()
.subscribe()
.also(::add)
}
/**
@ -276,13 +283,13 @@ class ReaderPresenter(
activeChapterSubscription?.unsubscribe()
activeChapterSubscription = getLoadObservable(loader, chapter)
.doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) }
.doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) }
.subscribeFirst({ view, _ ->
view.moveToPageIndex(0)
}, { _, _ ->
// Ignore onError event, viewers handle that state
})
.doOnSubscribe { isLoadingAdjacentChapterRelay.call(true) }
.doOnUnsubscribe { isLoadingAdjacentChapterRelay.call(false) }
.subscribeFirst({ view, _ ->
view.moveToPageIndex(0)
}, { _, _ ->
// Ignore onError event, viewers handle that state
})
}
/**
@ -299,12 +306,12 @@ class ReaderPresenter(
val loader = loader ?: return
loader.loadChapter(chapter)
.observeOn(AndroidSchedulers.mainThread())
// Update current chapters whenever a chapter is preloaded
.doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) }
.onErrorComplete()
.subscribe()
.also(::add)
.observeOn(AndroidSchedulers.mainThread())
// Update current chapters whenever a chapter is preloaded
.doOnCompleted { viewerChaptersRelay.value?.let(viewerChaptersRelay::call) }
.onErrorComplete()
.subscribe()
.also(::add)
}
/**
@ -348,9 +355,9 @@ class ReaderPresenter(
*/
private fun saveChapterProgress(chapter: ReaderChapter) {
db.updateChapterProgress(chapter.chapter).asRxCompletable()
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
}
/**
@ -412,18 +419,18 @@ class ReaderPresenter(
db.updateMangaViewer(manga).executeAsBlocking()
Observable.timer(250, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread())
.subscribeFirst({ view, _ ->
val currChapters = viewerChaptersRelay.value
if (currChapters != null) {
// Save current page
val currChapter = currChapters.currChapter
currChapter.requestedPage = currChapter.chapter.last_page_read
.subscribeFirst({ view, _ ->
val currChapters = viewerChaptersRelay.value
if (currChapters != null) {
// Save current page
val currChapter = currChapters.currChapter
currChapter.requestedPage = currChapter.chapter.last_page_read
// Emit manga and chapters to the new viewer
view.setManga(manga)
view.setChapters(currChapters)
}
})
// Emit manga and chapters to the new viewer
view.setManga(manga)
view.setChapters(currChapters)
}
})
}
/**
@ -439,7 +446,7 @@ class ReaderPresenter(
// Build destination file.
val filename = DiskUtil.buildValidFilename(
"${manga.currentTitle()} - ${chapter.name}".take(225)
"${manga.currentTitle()} - ${chapter.name}".take(225)
) + " - ${page.number}.${type.extension}"
val destFile = File(directory, filename)
@ -464,23 +471,25 @@ class ReaderPresenter(
notifier.onClear()
// Pictures directory.
val destDir = File(Environment.getExternalStorageDirectory().absolutePath +
val destDir = File(
Environment.getExternalStorageDirectory().absolutePath +
File.separator + Environment.DIRECTORY_PICTURES +
File.separator + "Tachiyomi")
File.separator + "Tachiyomi"
)
// Copy file in background.
Observable.fromCallable { saveImage(page, destDir, manga) }
.doOnNext { file ->
DiskUtil.scanMedia(context, file)
notifier.onComplete(file)
}
.doOnError { notifier.onError(it.message) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) },
{ view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) }
)
.doOnNext { file ->
DiskUtil.scanMedia(context, file)
notifier.onComplete(file)
}
.doOnError { notifier.onError(it.message) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, file -> view.onSaveImageResult(SaveImageResult.Success(file)) },
{ view, error -> view.onSaveImageResult(SaveImageResult.Error(error)) }
)
}
/**
@ -498,13 +507,13 @@ class ReaderPresenter(
val destDir = File(context.cacheDir, "shared_image")
Observable.fromCallable { destDir.deleteRecursively() } // Keep only the last shared file
.map { saveImage(page, destDir, manga) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, file -> view.onShareImageResult(file) },
{ _, _ -> /* Empty */ }
)
.map { saveImage(page, destDir, manga) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, file -> view.onShareImageResult(file) },
{ _, _ -> /* Empty */ }
)
}
/**
@ -516,29 +525,29 @@ class ReaderPresenter(
val stream = page.stream ?: return
Observable
.fromCallable {
if (manga.source == LocalSource.ID) {
val context = Injekt.get<Application>()
LocalSource.updateCover(context, manga, stream())
R.string.cover_updated
.fromCallable {
if (manga.source == LocalSource.ID) {
val context = Injekt.get<Application>()
LocalSource.updateCover(context, manga, stream())
R.string.cover_updated
SetAsCoverResult.Success
} else {
val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
if (manga.favorite) {
coverCache.copyToCache(thumbUrl, stream())
MangaImpl.setLastCoverFetch(manga.id!!, Date().time)
SetAsCoverResult.Success
} else {
val thumbUrl = manga.thumbnail_url ?: throw Exception("Image url not found")
if (manga.favorite) {
coverCache.copyToCache(thumbUrl, stream())
MangaImpl.setLastCoverFetch(manga.id!!, Date().time)
SetAsCoverResult.Success
} else {
SetAsCoverResult.AddToLibraryFirst
}
SetAsCoverResult.AddToLibraryFirst
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, result -> view.onSetAsCoverResult(result) },
{ view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) }
)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeFirst(
{ view, result -> view.onSetAsCoverResult(result) },
{ view, _ -> view.onSetAsCoverResult(SetAsCoverResult.Error) }
)
}
/**
@ -568,27 +577,24 @@ class ReaderPresenter(
val trackManager = Injekt.get<TrackManager>()
db.getTracks(manga).asRxSingle()
.flatMapCompletable { trackList ->
Completable.concat(trackList.map { track ->
val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged && chapterRead > track.last_chapter_read) {
// We wan't these to execute even if the presenter is destroyed so launch on GlobalScope
GlobalScope.launch {
withContext(Dispatchers.IO) {
val trackList = db.getTracks(manga).executeAsBlocking()
trackList.map { track ->
val service = trackManager.getService(track.sync_id)
if (service != null && service.isLogged && chapterRead > track.last_chapter_read) {
try {
track.last_chapter_read = chapterRead
// We wan't these to execute even if the presenter is destroyed and leaks
// for a while. The view can still be garbage collected.
Observable.defer { service.update(track) }
.map { db.insertTrack(track).executeAsBlocking() }
.toCompletable()
.onErrorComplete()
} else {
Completable.complete()
service.update(track)
db.insertTrack(track).executeAsBlocking()
} catch (e: Exception) {
Timber.e(e)
}
})
}
}
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
}
}
}
/**
@ -604,19 +610,19 @@ class ReaderPresenter(
if (removeAfterReadSlots == -1) return
Completable
.fromCallable {
// Position of the read chapter
val position = chapterList.indexOf(chapter)
.fromCallable {
// Position of the read chapter
val position = chapterList.indexOf(chapter)
// Retrieve chapter to delete according to preference
val chapterToDelete = chapterList.getOrNull(position - removeAfterReadSlots)
if (chapterToDelete != null) {
downloadManager.enqueueDeleteChapters(listOf(chapterToDelete.chapter), manga)
}
// Retrieve chapter to delete according to preference
val chapterToDelete = chapterList.getOrNull(position - removeAfterReadSlots)
if (chapterToDelete != null) {
downloadManager.enqueueDeleteChapters(listOf(chapterToDelete.chapter), manga)
}
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
}
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
}
/**
@ -625,9 +631,8 @@ class ReaderPresenter(
*/
private fun deletePendingChapters() {
Completable.fromCallable { downloadManager.deletePendingChapters() }
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
.onErrorComplete()
.subscribeOn(Schedulers.io())
.subscribe()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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