Browse Source

Hide API implementation from MAL service. Reorder methods and minor changes

len 8 years ago
parent
commit
725ceab00b

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt

@@ -3,7 +3,7 @@ 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
 
 class TrackManager(private val context: Context) {
 
@@ -13,7 +13,7 @@ class TrackManager(private val context: Context) {
         const val KITSU = 3
     }
 
-    val myAnimeList = MyAnimeList(context, MYANIMELIST)
+    val myAnimeList = Myanimelist(context, MYANIMELIST)
 
     val aniList = Anilist(context, ANILIST)
 

+ 9 - 9
app/src/main/java/eu/kanade/tachiyomi/data/track/TrackService.kt

@@ -38,12 +38,6 @@ abstract class TrackService(val id: Int) {
 
     abstract fun displayScore(track: Track): String
 
-    abstract fun login(username: String, password: String): Completable
-
-    open val isLogged: Boolean
-        get() = !getUsername().isEmpty() &&
-                !getPassword().isEmpty()
-
     abstract fun add(track: Track): Observable<Track>
 
     abstract fun update(track: Track): Observable<Track>
@@ -54,17 +48,23 @@ abstract class TrackService(val id: Int) {
 
     abstract fun refresh(track: Track): Observable<Track>
 
-    fun saveCredentials(username: String, password: String) {
-        preferences.setTrackCredentials(this, username, password)
-    }
+    abstract fun login(username: String, password: String): Completable
 
     @CallSuper
     open fun logout() {
         preferences.setTrackCredentials(this, "", "")
     }
 
+    open val isLogged: Boolean
+        get() = !getUsername().isEmpty() &&
+                !getPassword().isEmpty()
+
     fun getUsername() = preferences.trackUsername(this)
 
     fun getPassword() = preferences.trackPassword(this)
 
+    fun saveCredentials(username: String, password: String) {
+        preferences.setTrackCredentials(this, username, password)
+    }
+
 }

+ 32 - 37
app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/Anilist.kt

@@ -93,33 +93,6 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
         }
     }
 
-    override fun login(username: String, password: String) = login(password)
-
-    fun login(authCode: String): Completable {
-        return api.login(authCode)
-                // Save the token in the interceptor.
-                .doOnNext { interceptor.setAuth(it) }
-                // Obtain the authenticated user from the API.
-                .zipWith(api.getCurrentUser().map { pair ->
-                    preferences.anilistScoreType().set(pair.second)
-                    pair.first
-                }, { oauth, user -> Pair(user, oauth.refresh_token!!) })
-                // Save service credentials (username and refresh token).
-                .doOnNext { saveCredentials(it.first, it.second) }
-                // Logout on any error.
-                .doOnError { logout() }
-                .toCompletable()
-    }
-
-    override fun logout() {
-        super.logout()
-        interceptor.setAuth(null)
-    }
-
-    override fun search(query: String): Observable<List<Track>> {
-        return api.search(query)
-    }
-
     override fun add(track: Track): Observable<Track> {
         return api.addLibManga(track)
     }
@@ -133,7 +106,7 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
     }
 
     override fun bind(track: Track): Observable<Track> {
-        return api.findLibManga(getUsername(), track)
+        return api.findLibManga(track, getUsername())
                 .flatMap { remoteTrack ->
                     if (remoteTrack != null) {
                         track.copyPersonalFrom(remoteTrack)
@@ -147,19 +120,41 @@ class Anilist(private val context: Context, id: Int) : TrackService(id) {
                 }
     }
 
+    override fun search(query: String): Observable<List<Track>> {
+        return api.search(query)
+    }
+
     override fun refresh(track: Track): Observable<Track> {
-        // TODO getLibManga method?
-        return api.findLibManga(getUsername(), track)
+        return api.getLibManga(track, getUsername())
                 .map { remoteTrack ->
-                    if (remoteTrack != null) {
-                        track.copyPersonalFrom(remoteTrack)
-                        track.total_chapters = remoteTrack.total_chapters
-                        track
-                    } else {
-                        throw Exception("Could not find manga")
-                    }
+                    track.copyPersonalFrom(remoteTrack)
+                    track.total_chapters = remoteTrack.total_chapters
+                    track
                 }
     }
 
+    override fun login(username: String, password: String) = login(password)
+
+    fun login(authCode: String): Completable {
+        return api.login(authCode)
+                // Save the token in the interceptor.
+                .doOnNext { interceptor.setAuth(it) }
+                // Obtain the authenticated user from the API.
+                .zipWith(api.getCurrentUser().map { pair ->
+                    preferences.anilistScoreType().set(pair.second)
+                    pair.first
+                }, { oauth, user -> Pair(user, oauth.refresh_token!!) })
+                // Save service credentials (username and refresh token).
+                .doOnNext { saveCredentials(it.first, it.second) }
+                // Logout on any error.
+                .doOnError { logout() }
+                .toCompletable()
+    }
+
+    override fun logout() {
+        super.logout()
+        interceptor.setAuth(null)
+    }
+
 }
 

+ 42 - 29
app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt

@@ -23,22 +23,27 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
             .build()
             .create(Rest::class.java)
 
-    private fun restBuilder() = Retrofit.Builder()
-            .baseUrl(baseUrl)
-            .addConverterFactory(GsonConverterFactory.create())
-            .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
-
-    fun login(authCode: String): Observable<OAuth> {
-        return restBuilder()
-                .client(client)
-                .build()
-                .create(Rest::class.java)
-                .requestAccessToken(authCode)
+    fun addLibManga(track: Track): Observable<Track> {
+        return rest.addLibManga(track.remote_id, track.last_chapter_read, track.toAnilistStatus())
+                .map { response ->
+                    response.body().close()
+                    if (!response.isSuccessful) {
+                        throw Exception("Could not add manga")
+                    }
+                    track
+                }
     }
 
-    fun getCurrentUser(): Observable<Pair<String, Int>> {
-        return rest.getCurrentUser()
-                .map { it["id"].string to it["score_type"].int }
+    fun updateLibManga(track: Track): Observable<Track> {
+        return rest.updateLibManga(track.remote_id, track.last_chapter_read, track.toAnilistStatus(),
+                track.toAnilistScore())
+                .map { response ->
+                    response.body().close()
+                    if (!response.isSuccessful) {
+                        throw Exception("Could not update manga")
+                    }
+                    track
+                }
     }
 
     fun search(query: String): Observable<List<Track>> {
@@ -55,27 +60,35 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
                 }
     }
 
-    fun addLibManga(track: Track): Observable<Track> {
-        return rest.addLibManga(track.remote_id, track.last_chapter_read, track.toAnilistStatus())
-                .doOnNext { it.body().close() }
-                .doOnNext { if (!it.isSuccessful) throw Exception("Could not add manga") }
-                .map { track }
+    fun findLibManga(track: Track, username: String) : Observable<Track?> {
+        // TODO avoid getting the entire list
+        return getList(username)
+                .map { list -> list.find { it.remote_id == track.remote_id } }
     }
 
-    fun updateLibManga(track: Track): Observable<Track> {
-        return rest.updateLibManga(track.remote_id, track.last_chapter_read, track.toAnilistStatus(),
-                track.toAnilistScore())
-                .doOnNext { it.body().close() }
-                .doOnNext { if (!it.isSuccessful) throw Exception("Could not update manga") }
-                .map { track }
+    fun getLibManga(track: Track, username: String): Observable<Track> {
+        return findLibManga(track, username)
+                .map { it ?: throw Exception("Could not find manga") }
     }
 
-    fun findLibManga(username: String, track: Track) : Observable<Track?> {
-        // TODO avoid getting the entire list
-        return getList(username)
-                .map { list -> list.find { it.remote_id == track.remote_id } }
+    fun login(authCode: String): Observable<OAuth> {
+        return restBuilder()
+                .client(client)
+                .build()
+                .create(Rest::class.java)
+                .requestAccessToken(authCode)
+    }
+
+    fun getCurrentUser(): Observable<Pair<String, Int>> {
+        return rest.getCurrentUser()
+                .map { it["id"].string to it["score_type"].int }
     }
 
+    private fun restBuilder() = Retrofit.Builder()
+            .baseUrl(baseUrl)
+            .addConverterFactory(GsonConverterFactory.create())
+            .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
+
     private interface Rest {
 
         @FormUrlEncoded

+ 0 - 1
app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistInterceptor.kt

@@ -1,7 +1,6 @@
 package eu.kanade.tachiyomi.data.track.anilist
 
 import com.google.gson.Gson
-import eu.kanade.tachiyomi.data.track.anilist.OAuth
 import okhttp3.Interceptor
 import okhttp3.Response
 

+ 40 - 44
app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt

@@ -62,43 +62,20 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
         return track.toKitsuScore()
     }
 
-    private fun getUserId(): String {
-        return getPassword()
-    }
-
-    fun saveToken(oauth: OAuth?) {
-        val json = gson.toJson(oauth)
-        preferences.trackToken(this).set(json)
+    override fun add(track: Track): Observable<Track> {
+        return api.addLibManga(track, getUserId())
     }
 
-    fun restoreToken(): OAuth? {
-        return try {
-            gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
-        } catch (e: Exception) {
-            null
+    override fun update(track: Track): Observable<Track> {
+        if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
+            track.status = COMPLETED
         }
-    }
-
-    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 fun logout() {
-        super.logout()
-        interceptor.newAuth(null)
-    }
 
-    override fun search(query: String): Observable<List<Track>> {
-        return api.search(query)
+        return api.updateLibManga(track)
     }
 
     override fun bind(track: Track): Observable<Track> {
-        return find(track)
+        return api.findLibManga(track, getUserId())
                 .flatMap { remoteTrack ->
                     if (remoteTrack != null) {
                         track.copyPersonalFrom(remoteTrack)
@@ -112,20 +89,8 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
                 }
     }
 
-    private fun find(track: Track): Observable<Track?> {
-        return api.findLibManga(getUserId(), track.remote_id)
-    }
-
-    override fun add(track: Track): Observable<Track> {
-        return api.addLibManga(track, getUserId())
-    }
-
-    override fun update(track: Track): Observable<Track> {
-        if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
-            track.status = COMPLETED
-        }
-
-        return api.updateLibManga(track)
+    override fun search(query: String): Observable<List<Track>> {
+        return api.search(query)
     }
 
     override fun refresh(track: Track): Observable<Track> {
@@ -137,4 +102,35 @@ class Kitsu(private val context: Context, id: Int) : TrackService(id) {
                 }
     }
 
+    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 fun logout() {
+        super.logout()
+        interceptor.newAuth(null)
+    }
+
+    private fun getUserId(): String {
+        return getPassword()
+    }
+
+    fun saveToken(oauth: OAuth?) {
+        val json = gson.toJson(oauth)
+        preferences.trackToken(this).set(json)
+    }
+
+    fun restoreToken(): OAuth? {
+        return try {
+            gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java)
+        } catch (e: Exception) {
+            null
+        }
+    }
+
 }

+ 54 - 54
app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt

@@ -22,41 +22,6 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
             .build()
             .create(KitsuApi.Rest::class.java)
 
-    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 }
-    }
-
-    fun search(query: String): Observable<List<Track>> {
-        return rest.search(query)
-                .map { json ->
-                    val data = json["data"].array
-                    data.map { KitsuManga(it.obj).toTrack() }
-                }
-    }
-
-    fun findLibManga(userId: String, remoteId: Int): Observable<Track?> {
-        return rest.findLibManga(userId, remoteId)
-                .map { json ->
-                    val data = json["data"].array
-                    if (data.size() > 0) {
-                        KitsuLibManga(data[0].obj, json["included"].array[0].obj).toTrack()
-                    } else {
-                        null
-                    }
-                }
-    }
-
     fun addLibManga(track: Track, userId: String): Observable<Track> {
         return Observable.defer {
             // @formatter:off
@@ -110,6 +75,26 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
         }
     }
 
+    fun search(query: String): Observable<List<Track>> {
+        return rest.search(query)
+                .map { json ->
+                    val data = json["data"].array
+                    data.map { KitsuManga(it.obj).toTrack() }
+                }
+    }
+
+    fun findLibManga(track: Track, userId: String): Observable<Track?> {
+        return rest.findLibManga(track.remote_id, userId)
+                .map { json ->
+                    val data = json["data"].array
+                    if (data.size() > 0) {
+                        KitsuLibManga(data[0].obj, json["included"].array[0].obj).toTrack()
+                    } else {
+                        null
+                    }
+                }
+    }
+
     fun getLibManga(track: Track): Observable<Track> {
         return rest.getLibManga(track.remote_id)
                 .map { json ->
@@ -123,11 +108,34 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
                 }
     }
 
+    fun login(username: String, password: String): Observable<OAuth> {
+        return Retrofit.Builder()
+                .baseUrl(loginUrl)
+                .client(client)
+                .addConverterFactory(GsonConverterFactory.create())
+                .addCallAdapterFactory(RxJavaCallAdapterFactory.create())
+                .build()
+                .create(KitsuApi.LoginRest::class.java)
+                .requestAccessToken(username, password)
+    }
+
+    fun getCurrentUser(): Observable<String> {
+        return rest.getCurrentUser().map { it["data"].array[0]["id"].string }
+    }
+
     private interface Rest {
 
-        @GET("users")
-        fun getCurrentUser(
-                @Query("filter[self]", encoded = true) self: Boolean = true
+        @Headers("Content-Type: application/vnd.api+json")
+        @POST("library-entries")
+        fun addLibManga(
+                @Body data: JsonObject
+        ): Observable<JsonObject>
+
+        @Headers("Content-Type: application/vnd.api+json")
+        @PATCH("library-entries/{id}")
+        fun updateLibManga(
+                @Path("id") remoteId: Int,
+                @Body data: JsonObject
         ): Observable<JsonObject>
 
         @GET("manga")
@@ -135,31 +143,23 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
                 @Query("filter[text]", encoded = true) query: String
         ): Observable<JsonObject>
 
-        @GET("library-entries")
-        fun getLibManga(
-                @Query("filter[id]", encoded = true) remoteId: Int,
-                @Query("include") includes: String = "media"
-        ): Observable<JsonObject>
-
         @GET("library-entries")
         fun findLibManga(
-                @Query("filter[user_id]", encoded = true) userId: String,
                 @Query("filter[media_id]", encoded = true) remoteId: Int,
+                @Query("filter[user_id]", encoded = true) userId: String,
                 @Query("page[limit]", encoded = true) limit: Int = 10000,
                 @Query("include") includes: String = "media"
         ): Observable<JsonObject>
 
-        @Headers("Content-Type: application/vnd.api+json")
-        @POST("library-entries")
-        fun addLibManga(
-                @Body data: JsonObject
+        @GET("library-entries")
+        fun getLibManga(
+                @Query("filter[id]", encoded = true) remoteId: Int,
+                @Query("include") includes: String = "media"
         ): Observable<JsonObject>
 
-        @Headers("Content-Type: application/vnd.api+json")
-        @PATCH("library-entries/{id}")
-        fun updateLibManga(
-                @Path("id") remoteId: Int,
-                @Body data: JsonObject
+        @GET("users")
+        fun getCurrentUser(
+                @Query("filter[self]", encoded = true) self: Boolean = true
         ): Observable<JsonObject>
 
     }

+ 27 - 188
app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeList.kt

@@ -2,37 +2,15 @@ package eu.kanade.tachiyomi.data.track.myanimelist
 
 import android.content.Context
 import android.graphics.Color
-import android.net.Uri
-import android.util.Xml
 import eu.kanade.tachiyomi.R
 import eu.kanade.tachiyomi.data.database.models.Track
-import eu.kanade.tachiyomi.data.network.GET
-import eu.kanade.tachiyomi.data.network.POST
-import eu.kanade.tachiyomi.data.network.asObservable
 import eu.kanade.tachiyomi.data.track.TrackService
-import eu.kanade.tachiyomi.util.selectInt
-import eu.kanade.tachiyomi.util.selectText
-import okhttp3.Credentials
-import okhttp3.FormBody
-import okhttp3.Headers
-import okhttp3.RequestBody
-import org.jsoup.Jsoup
-import org.xmlpull.v1.XmlSerializer
 import rx.Completable
 import rx.Observable
-import java.io.StringWriter
 
-class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
-
-    private lateinit var headers: Headers
+class Myanimelist(private val context: Context, id: Int) : TrackService(id) {
 
     companion object {
-        const val BASE_URL = "https://myanimelist.net"
-
-        private val ENTRY_TAG = "entry"
-        private val CHAPTER_TAG = "chapter"
-        private val SCORE_TAG = "score"
-        private val STATUS_TAG = "status"
 
         const val READING = 1
         const val COMPLETED = 2
@@ -42,18 +20,9 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
 
         const val DEFAULT_STATUS = READING
         const val DEFAULT_SCORE = 0
-
-        const val PREFIX_MY = "my:"
     }
 
-    init {
-        val username = getUsername()
-        val password = getPassword()
-
-        if (!username.isEmpty() && !password.isEmpty()) {
-            createHeaders(username, password)
-        }
-    }
+    private val api by lazy { MyanimelistApi(client, getUsername(), getPassword()) }
 
     override val name: String
         get() = "MyAnimeList"
@@ -85,164 +54,21 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
         return track.score.toInt().toString()
     }
 
-    fun getLoginUrl() = Uri.parse(BASE_URL).buildUpon()
-            .appendEncodedPath("api/account/verify_credentials.xml")
-            .toString()
-
-    fun getSearchUrl(query: String) = Uri.parse(BASE_URL).buildUpon()
-            .appendEncodedPath("api/manga/search.xml")
-            .appendQueryParameter("q", query)
-            .toString()
-
-    fun getListUrl(username: String) = Uri.parse(BASE_URL).buildUpon()
-            .appendPath("malappinfo.php")
-            .appendQueryParameter("u", username)
-            .appendQueryParameter("status", "all")
-            .appendQueryParameter("type", "manga")
-            .toString()
-
-    fun getUpdateUrl(track: Track) = Uri.parse(BASE_URL).buildUpon()
-            .appendEncodedPath("api/mangalist/update")
-            .appendPath("${track.remote_id}.xml")
-            .toString()
-
-    fun getAddUrl(track: Track) = Uri.parse(BASE_URL).buildUpon()
-            .appendEncodedPath("api/mangalist/add")
-            .appendPath("${track.remote_id}.xml")
-            .toString()
-
-    override fun login(username: String, password: String): Completable {
-        createHeaders(username, password)
-        return client.newCall(GET(getLoginUrl(), headers))
-                .asObservable()
-                .doOnNext { it.close() }
-                .doOnNext { if (it.code() != 200) throw Exception("Login error") }
-                .doOnNext { saveCredentials(username, password) }
-                .doOnError { logout() }
-                .toCompletable()
-    }
-
-    override fun search(query: String): Observable<List<Track>> {
-        return if (query.startsWith(PREFIX_MY)) {
-            val realQuery = query.substring(PREFIX_MY.length).toLowerCase().trim()
-            getList()
-                    .flatMap { Observable.from(it) }
-                    .filter { realQuery in it.title.toLowerCase() }
-                    .toList()
-        } else {
-            client.newCall(GET(getSearchUrl(query), headers))
-                    .asObservable()
-                    .map { Jsoup.parse(it.body().string()) }
-                    .flatMap { Observable.from(it.select("entry")) }
-                    .filter { it.select("type").text() != "Novel" }
-                    .map {
-                        Track.create(id).apply {
-                            title = it.selectText("title")!!
-                            remote_id = it.selectInt("id")
-                            total_chapters = it.selectInt("chapters")
-                        }
-                    }
-                    .toList()
-        }
-    }
-
-    override fun refresh(track: Track): Observable<Track> {
-        return getList()
-                .map { myList ->
-                    val remoteTrack = myList.find { it.remote_id == track.remote_id }
-                    if (remoteTrack != null) {
-                        track.copyPersonalFrom(remoteTrack)
-                        track.total_chapters = remoteTrack.total_chapters
-                        track
-                    } else {
-                        throw Exception("Could not find manga")
-                    }
-                }
-    }
-
-    // MAL doesn't support score with decimals
-    fun getList(): Observable<List<Track>> {
-        return networkService.forceCacheClient
-                .newCall(GET(getListUrl(getUsername()), headers))
-                .asObservable()
-                .map { Jsoup.parse(it.body().string()) }
-                .flatMap { Observable.from(it.select("manga")) }
-                .map {
-                    Track.create(id).apply {
-                        title = it.selectText("series_title")!!
-                        remote_id = it.selectInt("series_mangadb_id")
-                        last_chapter_read = it.selectInt("my_read_chapters")
-                        status = it.selectInt("my_status")
-                        score = it.selectInt("my_score").toFloat()
-                        total_chapters = it.selectInt("series_chapters")
-                    }
-                }
-                .toList()
-    }
-
-    override fun update(track: Track): Observable<Track> {
-        return Observable.defer {
-            if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
-                track.status = COMPLETED
-            }
-            client.newCall(POST(getUpdateUrl(track), headers, getMangaPostPayload(track)))
-                    .asObservable()
-                    .doOnNext { it.close() }
-                    .doOnNext { if (!it.isSuccessful) throw Exception("Could not update manga") }
-                    .map { track }
-        }
-
-    }
-
     override fun add(track: Track): Observable<Track> {
-        return Observable.defer {
-            client.newCall(POST(getAddUrl(track), headers, getMangaPostPayload(track)))
-                    .asObservable()
-                    .doOnNext { it.close() }
-                    .doOnNext { if (!it.isSuccessful) throw Exception("Could not add manga") }
-                    .map { track }
-        }
+        return api.addLibManga(track)
     }
 
-    private fun getMangaPostPayload(track: Track): RequestBody {
-        val xml = Xml.newSerializer()
-        val writer = StringWriter()
-
-        with(xml) {
-            setOutput(writer)
-            startDocument("UTF-8", false)
-            startTag("", ENTRY_TAG)
-
-            // Last chapter read
-            if (track.last_chapter_read != 0) {
-                inTag(CHAPTER_TAG, track.last_chapter_read.toString())
-            }
-            // Manga status in the list
-            inTag(STATUS_TAG, track.status.toString())
-
-            // Manga score
-            inTag(SCORE_TAG, track.score.toString())
-
-            endTag("", ENTRY_TAG)
-            endDocument()
+    override fun update(track: Track): Observable<Track> {
+        if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) {
+            track.status = COMPLETED
         }
 
-        val form = FormBody.Builder()
-        form.add("data", writer.toString())
-        return form.build()
-    }
-
-    fun XmlSerializer.inTag(tag: String, body: String, namespace: String = "") {
-        startTag(namespace, tag)
-        text(body)
-        endTag(namespace, tag)
+        return api.updateLibManga(track)
     }
 
     override fun bind(track: Track): Observable<Track> {
-        return getList()
-                .flatMap { userlist ->
-                    track.sync_id = id
-                    val remoteTrack = userlist.find { it.remote_id == track.remote_id }
+        return api.findLibManga(track, getUsername())
+                .flatMap { remoteTrack ->
                     if (remoteTrack != null) {
                         track.copyPersonalFrom(remoteTrack)
                         update(track)
@@ -255,11 +81,24 @@ class MyAnimeList(private val context: Context, id: Int) : TrackService(id) {
                 }
     }
 
-    fun createHeaders(username: String, password: String) {
-        val builder = Headers.Builder()
-        builder.add("Authorization", Credentials.basic(username, password))
-        builder.add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C")
-        headers = builder.build()
+    override fun search(query: String): Observable<List<Track>> {
+        return api.search(query, getUsername())
+    }
+
+    override fun refresh(track: Track): Observable<Track> {
+        return api.getLibManga(track, getUsername())
+                .map { remoteTrack ->
+                    track.copyPersonalFrom(remoteTrack)
+                    track.total_chapters = remoteTrack.total_chapters
+                    track
+                }
+    }
+
+    override fun login(username: String, password: String): Completable {
+        return api.login(username, password)
+                .doOnNext { saveCredentials(username, password) }
+                .doOnError { logout() }
+                .toCompletable()
     }
 
 }

+ 190 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyanimelistApi.kt

@@ -0,0 +1,190 @@
+package eu.kanade.tachiyomi.data.track.myanimelist
+
+import android.net.Uri
+import android.util.Xml
+import eu.kanade.tachiyomi.data.database.models.Track
+import eu.kanade.tachiyomi.data.network.GET
+import eu.kanade.tachiyomi.data.network.POST
+import eu.kanade.tachiyomi.data.network.asObservable
+import eu.kanade.tachiyomi.data.track.TrackManager
+import eu.kanade.tachiyomi.util.selectInt
+import eu.kanade.tachiyomi.util.selectText
+import okhttp3.*
+import org.jsoup.Jsoup
+import org.xmlpull.v1.XmlSerializer
+import rx.Observable
+import java.io.StringWriter
+
+class MyanimelistApi(private val client: OkHttpClient, username: String, password: String) {
+
+    private var headers = createHeaders(username, password)
+
+    fun addLibManga(track: Track): Observable<Track> {
+        return Observable.defer {
+            client.newCall(POST(getAddUrl(track), headers, getMangaPostPayload(track)))
+                    .asObservable()
+                    .map { response ->
+                        response.body().close()
+                        if (!response.isSuccessful) {
+                            throw Exception("Could not add manga")
+                        }
+                        track
+                    }
+        }
+    }
+
+    fun updateLibManga(track: Track): Observable<Track> {
+        return Observable.defer {
+            client.newCall(POST(getUpdateUrl(track), headers, getMangaPostPayload(track)))
+                    .asObservable()
+                    .map { response ->
+                        response.body().close()
+                        if (!response.isSuccessful) {
+                            throw Exception("Could not update manga")
+                        }
+                        track
+                    }
+        }
+    }
+
+    fun search(query: String, username: String): Observable<List<Track>> {
+        return if (query.startsWith(PREFIX_MY)) {
+            val realQuery = query.substring(PREFIX_MY.length).toLowerCase().trim()
+            getList(username)
+                    .flatMap { Observable.from(it) }
+                    .filter { realQuery in it.title.toLowerCase() }
+                    .toList()
+        } else {
+            client.newCall(GET(getSearchUrl(query), headers))
+                    .asObservable()
+                    .map { Jsoup.parse(it.body().string()) }
+                    .flatMap { Observable.from(it.select("entry")) }
+                    .filter { it.select("type").text() != "Novel" }
+                    .map {
+                        Track.create(TrackManager.MYANIMELIST).apply {
+                            title = it.selectText("title")!!
+                            remote_id = it.selectInt("id")
+                            total_chapters = it.selectInt("chapters")
+                        }
+                    }
+                    .toList()
+        }
+    }
+
+    fun getList(username: String): Observable<List<Track>> {
+        return client
+                .newCall(GET(getListUrl(username), headers))
+                .asObservable()
+                .map { Jsoup.parse(it.body().string()) }
+                .flatMap { Observable.from(it.select("manga")) }
+                .map {
+                    Track.create(TrackManager.MYANIMELIST).apply {
+                        title = it.selectText("series_title")!!
+                        remote_id = it.selectInt("series_mangadb_id")
+                        last_chapter_read = it.selectInt("my_read_chapters")
+                        status = it.selectInt("my_status")
+                        score = it.selectInt("my_score").toFloat()
+                        total_chapters = it.selectInt("series_chapters")
+                    }
+                }
+                .toList()
+    }
+
+    fun findLibManga(track: Track, username: String): Observable<Track?> {
+        return getList(username)
+                .map { list -> list.find { it.remote_id == track.remote_id } }
+    }
+
+    fun getLibManga(track: Track, username: String): Observable<Track> {
+        return findLibManga(track, username)
+                .map { it ?: throw Exception("Could not find manga") }
+    }
+
+    fun login(username: String, password: String): Observable<Response> {
+        headers = createHeaders(username, password)
+        return client.newCall(GET(getLoginUrl(), headers))
+                .asObservable()
+                .doOnNext { response ->
+                    response.close()
+                    if (response.code() != 200) throw Exception("Login error")
+                }
+    }
+
+    private fun getMangaPostPayload(track: Track): RequestBody {
+        val xml = Xml.newSerializer()
+        val writer = StringWriter()
+
+        with(xml) {
+            setOutput(writer)
+            startDocument("UTF-8", false)
+            startTag("", ENTRY_TAG)
+
+            // Last chapter read
+            if (track.last_chapter_read != 0) {
+                inTag(CHAPTER_TAG, track.last_chapter_read.toString())
+            }
+            // Manga status in the list
+            inTag(STATUS_TAG, track.status.toString())
+
+            // Manga score
+            inTag(SCORE_TAG, track.score.toString())
+
+            endTag("", ENTRY_TAG)
+            endDocument()
+        }
+
+        val form = FormBody.Builder()
+        form.add("data", writer.toString())
+        return form.build()
+    }
+
+    fun XmlSerializer.inTag(tag: String, body: String, namespace: String = "") {
+        startTag(namespace, tag)
+        text(body)
+        endTag(namespace, tag)
+    }
+
+    fun getLoginUrl() = Uri.parse(baseUrl).buildUpon()
+            .appendEncodedPath("api/account/verify_credentials.xml")
+            .toString()
+
+    fun getSearchUrl(query: String) = Uri.parse(baseUrl).buildUpon()
+            .appendEncodedPath("api/manga/search.xml")
+            .appendQueryParameter("q", query)
+            .toString()
+
+    fun getListUrl(username: String) = Uri.parse(baseUrl).buildUpon()
+            .appendPath("malappinfo.php")
+            .appendQueryParameter("u", username)
+            .appendQueryParameter("status", "all")
+            .appendQueryParameter("type", "manga")
+            .toString()
+
+    fun getUpdateUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
+            .appendEncodedPath("api/mangalist/update")
+            .appendPath("${track.remote_id}.xml")
+            .toString()
+
+    fun getAddUrl(track: Track) = Uri.parse(baseUrl).buildUpon()
+            .appendEncodedPath("api/mangalist/add")
+            .appendPath("${track.remote_id}.xml")
+            .toString()
+
+    fun createHeaders(username: String, password: String): Headers {
+        return Headers.Builder()
+                .add("Authorization", Credentials.basic(username, password))
+                .add("User-Agent", "api-indiv-9F93C52A963974CF674325391990191C")
+                .build()
+    }
+
+    companion object {
+        const val baseUrl = "https://myanimelist.net"
+
+        private val ENTRY_TAG = "entry"
+        private val CHAPTER_TAG = "chapter"
+        private val SCORE_TAG = "score"
+        private val STATUS_TAG = "status"
+
+        const val PREFIX_MY = "my:"
+    }
+}