mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-31 22:37:56 +01:00 
			
		
		
		
	Experimental Anilist and Kitsu support (#586)
* Tracking tab with anilist support * Rename MangaSync to Track * Rename variables and methods to track * Kitsu implementation * Variables refactoring * Travis fix?
This commit is contained in:
		| @@ -39,7 +39,7 @@ class BackupManager(private val db: DatabaseHelper) { | ||||
|     private val MANGA = "manga" | ||||
|     private val MANGAS = "mangas" | ||||
|     private val CHAPTERS = "chapters" | ||||
|     private val MANGA_SYNC = "sync" | ||||
|     private val TRACK = "sync" | ||||
|     private val CATEGORIES = "categories" | ||||
|  | ||||
|     @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") | ||||
| @@ -109,10 +109,10 @@ class BackupManager(private val db: DatabaseHelper) { | ||||
|             entry.add(CHAPTERS, gson.toJsonTree(chapters)) | ||||
|         } | ||||
|  | ||||
|         // Backup manga sync | ||||
|         val mangaSync = db.getMangasSync(manga).executeAsBlocking() | ||||
|         if (!mangaSync.isEmpty()) { | ||||
|             entry.add(MANGA_SYNC, gson.toJsonTree(mangaSync)) | ||||
|         // Backup tracks | ||||
|         val tracks = db.getTracks(manga).executeAsBlocking() | ||||
|         if (!tracks.isEmpty()) { | ||||
|             entry.add(TRACK, gson.toJsonTree(tracks)) | ||||
|         } | ||||
|  | ||||
|         // Backup categories for this manga | ||||
| @@ -231,13 +231,13 @@ class BackupManager(private val db: DatabaseHelper) { | ||||
|             val element = backupManga.asJsonObject | ||||
|             val manga = gson.fromJson(element.get(MANGA), MangaImpl::class.java) | ||||
|             val chapters = gson.fromJson<List<ChapterImpl>>(element.get(CHAPTERS) ?: JsonArray()) | ||||
|             val sync = gson.fromJson<List<MangaSyncImpl>>(element.get(MANGA_SYNC) ?: JsonArray()) | ||||
|             val tracks = gson.fromJson<List<TrackImpl>>(element.get(TRACK) ?: JsonArray()) | ||||
|             val categories = gson.fromJson<List<String>>(element.get(CATEGORIES) ?: JsonArray()) | ||||
|  | ||||
|             // Restore everything related to this manga | ||||
|             restoreManga(manga) | ||||
|             restoreChaptersForManga(manga, chapters) | ||||
|             restoreSyncForManga(manga, sync) | ||||
|             restoreSyncForManga(manga, tracks) | ||||
|             restoreCategoriesForManga(manga, categories) | ||||
|         } | ||||
|     } | ||||
| @@ -333,35 +333,35 @@ class BackupManager(private val db: DatabaseHelper) { | ||||
|      * Restores the sync of a manga. | ||||
|      * | ||||
|      * @param manga the manga whose sync have to be restored. | ||||
|      * @param sync the sync to restore. | ||||
|      * @param tracks the track list to restore. | ||||
|      */ | ||||
|     private fun restoreSyncForManga(manga: Manga, sync: List<MangaSync>) { | ||||
|     private fun restoreSyncForManga(manga: Manga, tracks: List<Track>) { | ||||
|         // Fix foreign keys with the current manga id | ||||
|         for (mangaSync in sync) { | ||||
|             mangaSync.manga_id = manga.id!! | ||||
|         for (track in tracks) { | ||||
|             track.manga_id = manga.id!! | ||||
|         } | ||||
|  | ||||
|         val dbSyncs = db.getMangasSync(manga).executeAsBlocking() | ||||
|         val syncToUpdate = ArrayList<MangaSync>() | ||||
|         for (backupSync in sync) { | ||||
|         val dbTracks = db.getTracks(manga).executeAsBlocking() | ||||
|         val trackToUpdate = ArrayList<Track>() | ||||
|         for (backupTrack in tracks) { | ||||
|             // Try to find existing chapter in db | ||||
|             val pos = dbSyncs.indexOf(backupSync) | ||||
|             val pos = dbTracks.indexOf(backupTrack) | ||||
|             if (pos != -1) { | ||||
|                 // The sync is already in the db, only update its fields | ||||
|                 val dbSync = dbSyncs[pos] | ||||
|                 val dbSync = dbTracks[pos] | ||||
|                 // Mark the max chapter as read and nothing else | ||||
|                 dbSync.last_chapter_read = Math.max(backupSync.last_chapter_read, dbSync.last_chapter_read) | ||||
|                 syncToUpdate.add(dbSync) | ||||
|                 dbSync.last_chapter_read = Math.max(backupTrack.last_chapter_read, dbSync.last_chapter_read) | ||||
|                 trackToUpdate.add(dbSync) | ||||
|             } else { | ||||
|                 // Insert new sync. Let the db assign the id | ||||
|                 backupSync.id = null | ||||
|                 syncToUpdate.add(backupSync) | ||||
|                 backupTrack.id = null | ||||
|                 trackToUpdate.add(backupTrack) | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Update database | ||||
|         if (!syncToUpdate.isEmpty()) { | ||||
|             db.insertMangasSync(syncToUpdate).executeAsBlocking() | ||||
|         if (!trackToUpdate.isEmpty()) { | ||||
|             db.insertTracks(trackToUpdate).executeAsBlocking() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -5,7 +5,7 @@ import com.google.gson.FieldAttributes | ||||
| import eu.kanade.tachiyomi.data.database.models.CategoryImpl | ||||
| import eu.kanade.tachiyomi.data.database.models.ChapterImpl | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaImpl | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaSyncImpl | ||||
| import eu.kanade.tachiyomi.data.database.models.TrackImpl | ||||
|  | ||||
| class IdExclusion : ExclusionStrategy { | ||||
|  | ||||
| @@ -17,7 +17,7 @@ class IdExclusion : ExclusionStrategy { | ||||
|     override fun shouldSkipField(f: FieldAttributes) = when (f.declaringClass) { | ||||
|         MangaImpl::class.java -> mangaExclusions.contains(f.name) | ||||
|         ChapterImpl::class.java -> chapterExclusions.contains(f.name) | ||||
|         MangaSyncImpl::class.java -> syncExclusions.contains(f.name) | ||||
|         TrackImpl::class.java -> syncExclusions.contains(f.name) | ||||
|         CategoryImpl::class.java -> categoryExclusions.contains(f.name) | ||||
|         else -> false | ||||
|     } | ||||
|   | ||||
| @@ -10,13 +10,13 @@ import eu.kanade.tachiyomi.data.database.queries.* | ||||
|  * This class provides operations to manage the database through its interfaces. | ||||
|  */ | ||||
| open class DatabaseHelper(context: Context) | ||||
| : MangaQueries, ChapterQueries, MangaSyncQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries { | ||||
| : MangaQueries, ChapterQueries, TrackQueries, CategoryQueries, MangaCategoryQueries, HistoryQueries { | ||||
|  | ||||
|     override val db = DefaultStorIOSQLite.builder() | ||||
|             .sqliteOpenHelper(DbOpenHelper(context)) | ||||
|             .addTypeMapping(Manga::class.java, MangaTypeMapping()) | ||||
|             .addTypeMapping(Chapter::class.java, ChapterTypeMapping()) | ||||
|             .addTypeMapping(MangaSync::class.java, MangaSyncTypeMapping()) | ||||
|             .addTypeMapping(Track::class.java, TrackTypeMapping()) | ||||
|             .addTypeMapping(Category::class.java, CategoryTypeMapping()) | ||||
|             .addTypeMapping(MangaCategory::class.java, MangaCategoryTypeMapping()) | ||||
|             .addTypeMapping(History::class.java, HistoryTypeMapping()) | ||||
|   | ||||
| @@ -23,7 +23,7 @@ class DbOpenHelper(context: Context) | ||||
|     override fun onCreate(db: SQLiteDatabase) = with(db) { | ||||
|         execSQL(MangaTable.createTableQuery) | ||||
|         execSQL(ChapterTable.createTableQuery) | ||||
|         execSQL(MangaSyncTable.createTableQuery) | ||||
|         execSQL(TrackTable.createTableQuery) | ||||
|         execSQL(CategoryTable.createTableQuery) | ||||
|         execSQL(MangaCategoryTable.createTableQuery) | ||||
|         execSQL(HistoryTable.createTableQuery) | ||||
|   | ||||
| @@ -9,38 +9,38 @@ import com.pushtorefresh.storio.sqlite.operations.put.DefaultPutResolver | ||||
| import com.pushtorefresh.storio.sqlite.queries.DeleteQuery | ||||
| import com.pushtorefresh.storio.sqlite.queries.InsertQuery | ||||
| import com.pushtorefresh.storio.sqlite.queries.UpdateQuery | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaSync | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaSyncImpl | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_ID | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_LAST_CHAPTER_READ | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_MANGA_ID | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_REMOTE_ID | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_SCORE | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_STATUS | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_SYNC_ID | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_TITLE | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.COL_TOTAL_CHAPTERS | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable.TABLE | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.database.models.TrackImpl | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_ID | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_LAST_CHAPTER_READ | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_MANGA_ID | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_REMOTE_ID | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SCORE | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_STATUS | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_SYNC_ID | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TITLE | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable.COL_TOTAL_CHAPTERS | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable.TABLE | ||||
| 
 | ||||
| class MangaSyncTypeMapping : SQLiteTypeMapping<MangaSync>( | ||||
|         MangaSyncPutResolver(), | ||||
|         MangaSyncGetResolver(), | ||||
|         MangaSyncDeleteResolver() | ||||
| class TrackTypeMapping : SQLiteTypeMapping<Track>( | ||||
|         TrackPutResolver(), | ||||
|         TrackGetResolver(), | ||||
|         TrackDeleteResolver() | ||||
| ) | ||||
| 
 | ||||
| class MangaSyncPutResolver : DefaultPutResolver<MangaSync>() { | ||||
| class TrackPutResolver : DefaultPutResolver<Track>() { | ||||
| 
 | ||||
|     override fun mapToInsertQuery(obj: MangaSync) = InsertQuery.builder() | ||||
|     override fun mapToInsertQuery(obj: Track) = InsertQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .build() | ||||
| 
 | ||||
|     override fun mapToUpdateQuery(obj: MangaSync) = UpdateQuery.builder() | ||||
|     override fun mapToUpdateQuery(obj: Track) = UpdateQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .where("$COL_ID = ?") | ||||
|             .whereArgs(obj.id) | ||||
|             .build() | ||||
| 
 | ||||
|     override fun mapToContentValues(obj: MangaSync) = ContentValues(9).apply { | ||||
|     override fun mapToContentValues(obj: Track) = ContentValues(9).apply { | ||||
|         put(COL_ID, obj.id) | ||||
|         put(COL_MANGA_ID, obj.manga_id) | ||||
|         put(COL_SYNC_ID, obj.sync_id) | ||||
| @@ -53,9 +53,9 @@ class MangaSyncPutResolver : DefaultPutResolver<MangaSync>() { | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| class MangaSyncGetResolver : DefaultGetResolver<MangaSync>() { | ||||
| class TrackGetResolver : DefaultGetResolver<Track>() { | ||||
| 
 | ||||
|     override fun mapFromCursor(cursor: Cursor): MangaSync = MangaSyncImpl().apply { | ||||
|     override fun mapFromCursor(cursor: Cursor): Track = TrackImpl().apply { | ||||
|         id = cursor.getLong(cursor.getColumnIndex(COL_ID)) | ||||
|         manga_id = cursor.getLong(cursor.getColumnIndex(COL_MANGA_ID)) | ||||
|         sync_id = cursor.getInt(cursor.getColumnIndex(COL_SYNC_ID)) | ||||
| @@ -68,9 +68,9 @@ class MangaSyncGetResolver : DefaultGetResolver<MangaSync>() { | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| class MangaSyncDeleteResolver : DefaultDeleteResolver<MangaSync>() { | ||||
| class TrackDeleteResolver : DefaultDeleteResolver<Track>() { | ||||
| 
 | ||||
|     override fun mapToDeleteQuery(obj: MangaSync) = DeleteQuery.builder() | ||||
|     override fun mapToDeleteQuery(obj: Track) = DeleteQuery.builder() | ||||
|             .table(TABLE) | ||||
|             .where("$COL_ID = ?") | ||||
|             .whereArgs(obj.id) | ||||
| @@ -2,7 +2,7 @@ package eu.kanade.tachiyomi.data.database.models | ||||
| 
 | ||||
| import java.io.Serializable | ||||
| 
 | ||||
| interface MangaSync : Serializable { | ||||
| interface Track : Serializable { | ||||
| 
 | ||||
|     var id: Long? | ||||
| 
 | ||||
| @@ -24,7 +24,7 @@ interface MangaSync : Serializable { | ||||
| 
 | ||||
|     var update: Boolean | ||||
| 
 | ||||
|     fun copyPersonalFrom(other: MangaSync) { | ||||
|     fun copyPersonalFrom(other: Track) { | ||||
|         last_chapter_read = other.last_chapter_read | ||||
|         score = other.score | ||||
|         status = other.status | ||||
| @@ -32,7 +32,7 @@ interface MangaSync : Serializable { | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         fun create(serviceId: Int): MangaSync = MangaSyncImpl().apply { | ||||
|         fun create(serviceId: Int): Track = TrackImpl().apply { | ||||
|             sync_id = serviceId | ||||
|         } | ||||
|     } | ||||
| @@ -1,6 +1,6 @@ | ||||
| package eu.kanade.tachiyomi.data.database.models | ||||
| 
 | ||||
| class MangaSyncImpl : MangaSync { | ||||
| class TrackImpl : Track { | ||||
| 
 | ||||
|     override var id: Long? = null | ||||
| 
 | ||||
| @@ -26,11 +26,11 @@ class MangaSyncImpl : MangaSync { | ||||
|         if (this === other) return true | ||||
|         if (other == null || javaClass != other.javaClass) return false | ||||
| 
 | ||||
|         val mangaSync = other as MangaSync | ||||
|         other as Track | ||||
| 
 | ||||
|         if (manga_id != mangaSync.manga_id) return false | ||||
|         if (sync_id != mangaSync.sync_id) return false | ||||
|         return remote_id == mangaSync.remote_id | ||||
|         if (manga_id != other.manga_id) return false | ||||
|         if (sync_id != other.sync_id) return false | ||||
|         return remote_id == other.remote_id | ||||
|     } | ||||
| 
 | ||||
|     override fun hashCode(): Int { | ||||
| @@ -1,46 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.data.database.queries | ||||
|  | ||||
| import com.pushtorefresh.storio.sqlite.queries.DeleteQuery | ||||
| import com.pushtorefresh.storio.sqlite.queries.Query | ||||
| import eu.kanade.tachiyomi.data.database.DbProvider | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaSync | ||||
| import eu.kanade.tachiyomi.data.database.tables.MangaSyncTable | ||||
| import eu.kanade.tachiyomi.data.mangasync.MangaSyncService | ||||
|  | ||||
| interface MangaSyncQueries : DbProvider { | ||||
|  | ||||
|     fun getMangaSync(manga: Manga, sync: MangaSyncService) = db.get() | ||||
|             .`object`(MangaSync::class.java) | ||||
|             .withQuery(Query.builder() | ||||
|                     .table(MangaSyncTable.TABLE) | ||||
|                     .where("${MangaSyncTable.COL_MANGA_ID} = ? AND " + | ||||
|                             "${MangaSyncTable.COL_SYNC_ID} = ?") | ||||
|                     .whereArgs(manga.id, sync.id) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|  | ||||
|     fun getMangasSync(manga: Manga) = db.get() | ||||
|             .listOfObjects(MangaSync::class.java) | ||||
|             .withQuery(Query.builder() | ||||
|                     .table(MangaSyncTable.TABLE) | ||||
|                     .where("${MangaSyncTable.COL_MANGA_ID} = ?") | ||||
|                     .whereArgs(manga.id) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|  | ||||
|     fun insertMangaSync(manga: MangaSync) = db.put().`object`(manga).prepare() | ||||
|  | ||||
|     fun insertMangasSync(mangas: List<MangaSync>) = db.put().objects(mangas).prepare() | ||||
|  | ||||
|     fun deleteMangaSync(manga: MangaSync) = db.delete().`object`(manga).prepare() | ||||
|  | ||||
|     fun deleteMangaSyncForManga(manga: Manga) = db.delete() | ||||
|             .byQuery(DeleteQuery.builder() | ||||
|                     .table(MangaSyncTable.TABLE) | ||||
|                     .where("${MangaSyncTable.COL_MANGA_ID} = ?") | ||||
|                     .whereArgs(manga.id) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,34 @@ | ||||
| package eu.kanade.tachiyomi.data.database.queries | ||||
|  | ||||
| import com.pushtorefresh.storio.sqlite.queries.DeleteQuery | ||||
| import com.pushtorefresh.storio.sqlite.queries.Query | ||||
| import eu.kanade.tachiyomi.data.database.DbProvider | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.database.tables.TrackTable | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
|  | ||||
| interface TrackQueries : DbProvider { | ||||
|  | ||||
|     fun getTracks(manga: Manga) = db.get() | ||||
|             .listOfObjects(Track::class.java) | ||||
|             .withQuery(Query.builder() | ||||
|                     .table(TrackTable.TABLE) | ||||
|                     .where("${TrackTable.COL_MANGA_ID} = ?") | ||||
|                     .whereArgs(manga.id) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|  | ||||
|     fun insertTrack(track: Track) = db.put().`object`(track).prepare() | ||||
|  | ||||
|     fun insertTracks(tracks: List<Track>) = db.put().objects(tracks).prepare() | ||||
|  | ||||
|     fun deleteTrackForManga(manga: Manga, sync: TrackService) = db.delete() | ||||
|             .byQuery(DeleteQuery.builder() | ||||
|                     .table(TrackTable.TABLE) | ||||
|                     .where("${TrackTable.COL_MANGA_ID} = ? AND ${TrackTable.COL_SYNC_ID} = ?") | ||||
|                     .whereArgs(manga.id, sync.id) | ||||
|                     .build()) | ||||
|             .prepare() | ||||
|  | ||||
| } | ||||
| @@ -1,6 +1,6 @@ | ||||
| package eu.kanade.tachiyomi.data.database.tables | ||||
| 
 | ||||
| object MangaSyncTable { | ||||
| object TrackTable { | ||||
| 
 | ||||
|     const val TABLE = "manga_sync" | ||||
| 
 | ||||
| @@ -1,23 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.data.mangasync | ||||
|  | ||||
| import android.content.Context | ||||
| import eu.kanade.tachiyomi.data.mangasync.anilist.Anilist | ||||
| import eu.kanade.tachiyomi.data.mangasync.myanimelist.MyAnimeList | ||||
|  | ||||
| class MangaSyncManager(private val context: Context) { | ||||
|  | ||||
|     companion object { | ||||
|         const val MYANIMELIST = 1 | ||||
|         const val ANILIST = 2 | ||||
|     } | ||||
|  | ||||
|     val myAnimeList = MyAnimeList(context, MYANIMELIST) | ||||
|  | ||||
|     val aniList = Anilist(context, ANILIST) | ||||
|  | ||||
|     // TODO enable anilist | ||||
|     val services = listOf(myAnimeList) | ||||
|  | ||||
|     fun getService(id: Int) = services.find { it.id == id } | ||||
|  | ||||
| } | ||||
| @@ -1,51 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.data.mangasync | ||||
|  | ||||
| import android.content.Context | ||||
| import android.support.annotation.CallSuper | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaSync | ||||
| import eu.kanade.tachiyomi.data.network.NetworkHelper | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import okhttp3.OkHttpClient | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| abstract class MangaSyncService(private val context: Context, val id: Int) { | ||||
|  | ||||
|     val preferences: PreferencesHelper by injectLazy() | ||||
|     val networkService: NetworkHelper by injectLazy() | ||||
|  | ||||
|     open val client: OkHttpClient | ||||
|         get() = networkService.client | ||||
|  | ||||
|     // Name of the manga sync service to display | ||||
|     abstract val name: String | ||||
|  | ||||
|     abstract fun login(username: String, password: String): Completable | ||||
|  | ||||
|     open val isLogged: Boolean | ||||
|         get() = !getUsername().isEmpty() && | ||||
|                 !getPassword().isEmpty() | ||||
|  | ||||
|     abstract fun add(manga: MangaSync): Observable<MangaSync> | ||||
|  | ||||
|     abstract fun update(manga: MangaSync): Observable<MangaSync> | ||||
|  | ||||
|     abstract fun bind(manga: MangaSync): Observable<MangaSync> | ||||
|  | ||||
|     abstract fun getStatus(status: Int): String | ||||
|  | ||||
|     fun saveCredentials(username: String, password: String) { | ||||
|         preferences.setMangaSyncCredentials(this, username, password) | ||||
|     } | ||||
|  | ||||
|     @CallSuper | ||||
|     open fun logout() { | ||||
|         preferences.setMangaSyncCredentials(this, "", "") | ||||
|     } | ||||
|  | ||||
|     fun getUsername() = preferences.mangaSyncUsername(this) | ||||
|  | ||||
|     fun getPassword() = preferences.mangaSyncPassword(this) | ||||
|  | ||||
| } | ||||
| @@ -1,132 +0,0 @@ | ||||
| package eu.kanade.tachiyomi.data.mangasync.anilist | ||||
|  | ||||
| import android.content.Context | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaSync | ||||
| import eu.kanade.tachiyomi.data.mangasync.MangaSyncService | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import timber.log.Timber | ||||
|  | ||||
| class Anilist(private val context: Context, id: Int) : MangaSyncService(context, 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 = 5 | ||||
|  | ||||
|         const val DEFAULT_STATUS = READING | ||||
|         const val DEFAULT_SCORE = 0 | ||||
|     } | ||||
|  | ||||
|     override val name = "AniList" | ||||
|  | ||||
|     private val interceptor by lazy { AnilistInterceptor(getPassword()) } | ||||
|  | ||||
|     private val api by lazy { | ||||
|         AnilistApi.createService(networkService.client.newBuilder() | ||||
|                 .addInterceptor(interceptor) | ||||
|                 .build()) | ||||
|     } | ||||
|  | ||||
|     override fun login(username: String, password: String) = login(password) | ||||
|  | ||||
|     fun login(authCode: String): Completable { | ||||
|         // Create a new api with the default client to avoid request interceptions. | ||||
|         return AnilistApi.createService(client) | ||||
|                 // Request the access token from the API with the authorization code. | ||||
|                 .requestAccessToken(authCode) | ||||
|                 // Save the token in the interceptor. | ||||
|                 .doOnNext { interceptor.setAuth(it) } | ||||
|                 // Obtain the authenticated user from the API. | ||||
|                 .zipWith(api.getCurrentUser().map { it["id"].toString() }) | ||||
|                         { 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) | ||||
|     } | ||||
|  | ||||
|     fun search(query: String): Observable<List<MangaSync>> { | ||||
|         return api.search(query, 1) | ||||
|                 .flatMap { Observable.from(it) } | ||||
|                 .filter { it.type != "Novel" } | ||||
|                 .map { it.toMangaSync() } | ||||
|                 .toList() | ||||
|     } | ||||
|  | ||||
|     fun getList(): Observable<List<MangaSync>> { | ||||
|         return api.getList(getUsername()) | ||||
|                 .flatMap { Observable.from(it.flatten()) } | ||||
|                 .map { it.toMangaSync() } | ||||
|                 .toList() | ||||
|     } | ||||
|  | ||||
|     override fun add(manga: MangaSync): Observable<MangaSync> { | ||||
|         return api.addManga(manga.remote_id, manga.last_chapter_read, manga.getAnilistStatus(), | ||||
|                 manga.score.toInt()) | ||||
|                 .doOnNext { it.body().close() } | ||||
|                 .doOnNext { if (!it.isSuccessful) throw Exception("Could not add manga") } | ||||
|                 .doOnError { Timber.e(it, it.message) } | ||||
|                 .map { manga } | ||||
|     } | ||||
|  | ||||
|     override fun update(manga: MangaSync): Observable<MangaSync> { | ||||
|         if (manga.total_chapters != 0 && manga.last_chapter_read == manga.total_chapters) { | ||||
|             manga.status = COMPLETED | ||||
|         } | ||||
|         return api.updateManga(manga.remote_id, manga.last_chapter_read, manga.getAnilistStatus(), | ||||
|                 manga.score.toInt()) | ||||
|                 .doOnNext { it.body().close() } | ||||
|                 .doOnNext { if (!it.isSuccessful) throw Exception("Could not update manga") } | ||||
|                 .doOnError { Timber.e(it, it.message) } | ||||
|                 .map { manga } | ||||
|     } | ||||
|  | ||||
|     override fun bind(manga: MangaSync): Observable<MangaSync> { | ||||
|         return getList() | ||||
|                 .flatMap { userlist -> | ||||
|                     manga.sync_id = id | ||||
|                     val mangaFromList = userlist.find { it.remote_id == manga.remote_id } | ||||
|                     if (mangaFromList != null) { | ||||
|                         manga.copyPersonalFrom(mangaFromList) | ||||
|                         update(manga) | ||||
|                     } else { | ||||
|                         // Set default fields if it's not found in the list | ||||
|                         manga.score = DEFAULT_SCORE.toFloat() | ||||
|                         manga.status = DEFAULT_STATUS | ||||
|                         add(manga) | ||||
|                     } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun getStatus(status: Int): String = with(context) { | ||||
|         when (status) { | ||||
|             READING -> getString(R.string.reading) | ||||
|             COMPLETED -> getString(R.string.completed) | ||||
|             ON_HOLD -> getString(R.string.on_hold) | ||||
|             DROPPED -> getString(R.string.dropped) | ||||
|             PLAN_TO_READ -> getString(R.string.plan_to_read) | ||||
|             else -> "" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun MangaSync.getAnilistStatus() = when (status) { | ||||
|         READING -> "reading" | ||||
|         COMPLETED -> "completed" | ||||
|         ON_HOLD -> "on-hold" | ||||
|         DROPPED -> "dropped" | ||||
|         PLAN_TO_READ -> "plan to read" | ||||
|         else -> throw NotImplementedError("Unknown status") | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| @@ -51,9 +51,9 @@ class PreferenceKeys(context: Context) { | ||||
|  | ||||
|     val updateOnlyNonCompleted = context.getString(R.string.pref_update_only_non_completed_key) | ||||
|  | ||||
|     val autoUpdateMangaSync = context.getString(R.string.pref_auto_update_manga_sync_key) | ||||
|     val autoUpdateTrack = context.getString(R.string.pref_auto_update_manga_sync_key) | ||||
|  | ||||
|     val askUpdateMangaSync = context.getString(R.string.pref_ask_update_manga_sync_key) | ||||
|     val askUpdateTrack = context.getString(R.string.pref_ask_update_manga_sync_key) | ||||
|  | ||||
|     val lastUsedCatalogueSource = context.getString(R.string.pref_last_catalogue_source_key) | ||||
|  | ||||
| @@ -95,9 +95,11 @@ class PreferenceKeys(context: Context) { | ||||
|  | ||||
|     fun sourcePassword(sourceId: Int) = "pref_source_password_$sourceId" | ||||
|  | ||||
|     fun syncUsername(syncId: Int) = "pref_mangasync_username_$syncId" | ||||
|     fun trackUsername(syncId: Int) = "pref_mangasync_username_$syncId" | ||||
|  | ||||
|     fun syncPassword(syncId: Int) = "pref_mangasync_password_$syncId" | ||||
|     fun trackPassword(syncId: Int) = "pref_mangasync_password_$syncId" | ||||
|  | ||||
|     fun trackToken(syncId: Int) = "track_token_$syncId" | ||||
|  | ||||
|     val libraryAsList = context.getString(R.string.pref_display_library_as_list) | ||||
|  | ||||
|   | ||||
| @@ -7,8 +7,8 @@ import android.preference.PreferenceManager | ||||
| import com.f2prateek.rx.preferences.Preference | ||||
| import com.f2prateek.rx.preferences.RxSharedPreferences | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.mangasync.MangaSyncService | ||||
| import eu.kanade.tachiyomi.data.source.Source | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import java.io.File | ||||
|  | ||||
| fun <T> Preference<T>.getOrDefault(): T = get() ?: defaultValue()!! | ||||
| @@ -70,9 +70,9 @@ class PreferencesHelper(val context: Context) { | ||||
|  | ||||
|     fun updateOnlyNonCompleted() = prefs.getBoolean(keys.updateOnlyNonCompleted, false) | ||||
|  | ||||
|     fun autoUpdateMangaSync() = prefs.getBoolean(keys.autoUpdateMangaSync, true) | ||||
|     fun autoUpdateTrack() = prefs.getBoolean(keys.autoUpdateTrack, true) | ||||
|  | ||||
|     fun askUpdateMangaSync() = prefs.getBoolean(keys.askUpdateMangaSync, false) | ||||
|     fun askUpdateTrack() = prefs.getBoolean(keys.askUpdateTrack, false) | ||||
|  | ||||
|     fun lastUsedCatalogueSource() = rxPrefs.getInteger(keys.lastUsedCatalogueSource, -1) | ||||
|  | ||||
| @@ -95,17 +95,21 @@ class PreferencesHelper(val context: Context) { | ||||
|                 .apply() | ||||
|     } | ||||
|  | ||||
|     fun mangaSyncUsername(sync: MangaSyncService) = prefs.getString(keys.syncUsername(sync.id), "") | ||||
|     fun trackUsername(sync: TrackService) = prefs.getString(keys.trackUsername(sync.id), "") | ||||
|  | ||||
|     fun mangaSyncPassword(sync: MangaSyncService) = prefs.getString(keys.syncPassword(sync.id), "") | ||||
|     fun trackPassword(sync: TrackService) = prefs.getString(keys.trackPassword(sync.id), "") | ||||
|  | ||||
|     fun setMangaSyncCredentials(sync: MangaSyncService, username: String, password: String) { | ||||
|     fun setTrackCredentials(sync: TrackService, username: String, password: String) { | ||||
|         prefs.edit() | ||||
|                 .putString(keys.syncUsername(sync.id), username) | ||||
|                 .putString(keys.syncPassword(sync.id), password) | ||||
|                 .putString(keys.trackUsername(sync.id), username) | ||||
|                 .putString(keys.trackPassword(sync.id), password) | ||||
|                 .apply() | ||||
|     } | ||||
|  | ||||
|     fun trackToken(sync: TrackService) = rxPrefs.getString(keys.trackToken(sync.id), "") | ||||
|  | ||||
|     fun anilistScoreType() = rxPrefs.getInteger("anilist_score_type", 0) | ||||
|  | ||||
|     fun downloadsDirectory() = rxPrefs.getString(keys.downloadsDirectory, defaultDownloadsDir.toString()) | ||||
|  | ||||
|     fun downloadThreads() = rxPrefs.getInteger(keys.downloadThreads, 1) | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| package eu.kanade.tachiyomi.data.source.online.english | ||||
|  | ||||
| import android.content.Context | ||||
| import eu.kanade.tachiyomi.data.database.models.Chapter | ||||
| import eu.kanade.tachiyomi.data.database.models.Manga | ||||
| import eu.kanade.tachiyomi.data.source.EN | ||||
|   | ||||
| @@ -0,0 +1,28 @@ | ||||
| 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 | ||||
|  | ||||
| class TrackManager(private val context: Context) { | ||||
|  | ||||
|     companion object { | ||||
|         const val MYANIMELIST = 1 | ||||
|         const val ANILIST = 2 | ||||
|         const val KITSU = 3 | ||||
|     } | ||||
|  | ||||
|     val myAnimeList = MyAnimeList(context, MYANIMELIST) | ||||
|  | ||||
|     val aniList = Anilist(context, ANILIST) | ||||
|  | ||||
|     val kitsu = Kitsu(context, KITSU) | ||||
|  | ||||
|     val services = listOf(myAnimeList, aniList, kitsu) | ||||
|  | ||||
|     fun getService(id: Int) = services.find { it.id == id } | ||||
|  | ||||
|     fun hasLoggedServices() = services.any { it.isLogged } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,67 @@ | ||||
| package eu.kanade.tachiyomi.data.track | ||||
|  | ||||
| import android.support.annotation.CallSuper | ||||
| import android.support.annotation.DrawableRes | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.network.NetworkHelper | ||||
| import eu.kanade.tachiyomi.data.preference.PreferencesHelper | ||||
| import okhttp3.OkHttpClient | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| abstract class TrackService(val id: Int) { | ||||
|  | ||||
|     val preferences: PreferencesHelper by injectLazy() | ||||
|     val networkService: NetworkHelper by injectLazy() | ||||
|  | ||||
|     open val client: OkHttpClient | ||||
|         get() = networkService.client | ||||
|  | ||||
|     // Name of the manga sync service to display | ||||
|     abstract val name: 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> | ||||
|  | ||||
|     abstract fun bind(track: Track): Observable<Track> | ||||
|  | ||||
|     abstract fun search(query: String): Observable<List<Track>> | ||||
|  | ||||
|     abstract fun refresh(track: Track): Observable<Track> | ||||
|  | ||||
|     abstract fun getStatus(status: Int): String | ||||
|  | ||||
|     abstract fun getStatusList(): List<Int> | ||||
|  | ||||
|     @DrawableRes | ||||
|     abstract fun getLogo(): Int | ||||
|  | ||||
|     abstract fun getLogoColor(): Int | ||||
|  | ||||
|     // TODO better support (decimals) | ||||
|     abstract fun maxScore(): Int | ||||
|  | ||||
|     abstract fun formatScore(track: Track): String | ||||
|  | ||||
|     fun saveCredentials(username: String, password: String) { | ||||
|         preferences.setTrackCredentials(this, username, password) | ||||
|     } | ||||
|  | ||||
|     @CallSuper | ||||
|     open fun logout() { | ||||
|         preferences.setTrackCredentials(this, "", "") | ||||
|     } | ||||
|  | ||||
|     fun getUsername() = preferences.trackUsername(this) | ||||
|  | ||||
|     fun getPassword() = preferences.trackPassword(this) | ||||
|  | ||||
| } | ||||
| @@ -1,20 +1,20 @@ | ||||
| package eu.kanade.tachiyomi.data.mangasync | ||||
| package eu.kanade.tachiyomi.data.track | ||||
| 
 | ||||
| import android.app.Service | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.os.IBinder | ||||
| import eu.kanade.tachiyomi.data.database.DatabaseHelper | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaSync | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import rx.Observable | ||||
| import rx.android.schedulers.AndroidSchedulers | ||||
| import rx.schedulers.Schedulers | ||||
| import rx.subscriptions.CompositeSubscription | ||||
| import uy.kohesive.injekt.injectLazy | ||||
| 
 | ||||
| class UpdateMangaSyncService : Service() { | ||||
| class TrackUpdateService : Service() { | ||||
| 
 | ||||
|     val syncManager: MangaSyncManager by injectLazy() | ||||
|     val trackManager: TrackManager by injectLazy() | ||||
|     val db: DatabaseHelper by injectLazy() | ||||
| 
 | ||||
|     private lateinit var subscriptions: CompositeSubscription | ||||
| @@ -30,9 +30,9 @@ class UpdateMangaSyncService : Service() { | ||||
|     } | ||||
| 
 | ||||
|     override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { | ||||
|         val manga = intent.getSerializableExtra(EXTRA_MANGASYNC) | ||||
|         if (manga != null) { | ||||
|             updateLastChapterRead(manga as MangaSync, startId) | ||||
|         val track = intent.getSerializableExtra(EXTRA_TRACK) | ||||
|         if (track != null) { | ||||
|             updateLastChapterRead(track as Track, startId) | ||||
|             return Service.START_REDELIVER_INTENT | ||||
|         } else { | ||||
|             stopSelf(startId) | ||||
| @@ -44,15 +44,15 @@ class UpdateMangaSyncService : Service() { | ||||
|         return null | ||||
|     } | ||||
| 
 | ||||
|     private fun updateLastChapterRead(mangaSync: MangaSync, startId: Int) { | ||||
|         val sync = syncManager.getService(mangaSync.sync_id) | ||||
|     private fun updateLastChapterRead(track: Track, startId: Int) { | ||||
|         val sync = trackManager.getService(track.sync_id) | ||||
|         if (sync == null) { | ||||
|             stopSelf(startId) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|         subscriptions.add(Observable.defer { sync.update(mangaSync) } | ||||
|                 .flatMap { db.insertMangaSync(mangaSync).asRxObservable() } | ||||
|         subscriptions.add(Observable.defer { sync.update(track) } | ||||
|                 .flatMap { db.insertTrack(track).asRxObservable() } | ||||
|                 .subscribeOn(Schedulers.io()) | ||||
|                 .observeOn(AndroidSchedulers.mainThread()) | ||||
|                 .subscribe({ stopSelf(startId) }, | ||||
| @@ -61,12 +61,12 @@ class UpdateMangaSyncService : Service() { | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         private val EXTRA_MANGASYNC = "extra_mangasync" | ||||
|         private val EXTRA_TRACK = "extra_track" | ||||
| 
 | ||||
|         @JvmStatic | ||||
|         fun start(context: Context, mangaSync: MangaSync) { | ||||
|             val intent = Intent(context, UpdateMangaSyncService::class.java) | ||||
|             intent.putExtra(EXTRA_MANGASYNC, mangaSync) | ||||
|         fun start(context: Context, track: Track) { | ||||
|             val intent = Intent(context, TrackUpdateService::class.java) | ||||
|             intent.putExtra(EXTRA_TRACK, track) | ||||
|             context.startService(intent) | ||||
|         } | ||||
|     } | ||||
| @@ -0,0 +1,191 @@ | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.Color | ||||
| import com.github.salomonbrys.kotson.int | ||||
| import com.github.salomonbrys.kotson.string | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.preference.getOrDefault | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import timber.log.Timber | ||||
|  | ||||
| class Anilist(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 = 5 | ||||
|  | ||||
|         const val DEFAULT_STATUS = READING | ||||
|         const val DEFAULT_SCORE = 0 | ||||
|     } | ||||
|  | ||||
|     override val name = "AniList" | ||||
|  | ||||
|     private val interceptor by lazy { AnilistInterceptor(getPassword()) } | ||||
|  | ||||
|     private val api by lazy { | ||||
|         AnilistApi.createService(networkService.client.newBuilder() | ||||
|                 .addInterceptor(interceptor) | ||||
|                 .build()) | ||||
|     } | ||||
|  | ||||
|     override fun getLogo() = R.drawable.al | ||||
|  | ||||
|     override fun getLogoColor() = Color.rgb(18, 25, 35) | ||||
|  | ||||
|     override fun maxScore() = 100 | ||||
|  | ||||
|     override fun login(username: String, password: String) = login(password) | ||||
|  | ||||
|     fun login(authCode: String): Completable { | ||||
|         // Create a new api with the default client to avoid request interceptions. | ||||
|         return AnilistApi.createService(client) | ||||
|                 // Request the access token from the API with the authorization code. | ||||
|                 .requestAccessToken(authCode) | ||||
|                 // Save the token in the interceptor. | ||||
|                 .doOnNext { interceptor.setAuth(it) } | ||||
|                 // Obtain the authenticated user from the API. | ||||
|                 .zipWith(api.getCurrentUser().map { | ||||
|                     preferences.anilistScoreType().set(it["score_type"].int) | ||||
|                     it["id"].string | ||||
|                 }, { 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, 1) | ||||
|                 .flatMap { Observable.from(it) } | ||||
|                 .filter { it.type != "Novel" } | ||||
|                 .map { it.toTrack() } | ||||
|                 .toList() | ||||
|     } | ||||
|  | ||||
|     fun getList(): Observable<List<Track>> { | ||||
|         return api.getList(getUsername()) | ||||
|                 .flatMap { Observable.from(it.flatten()) } | ||||
|                 .map { it.toTrack() } | ||||
|                 .toList() | ||||
|     } | ||||
|  | ||||
|     override fun add(track: Track): Observable<Track> { | ||||
|         return api.addManga(track.remote_id, track.last_chapter_read, track.getAnilistStatus()) | ||||
|                 .doOnNext { it.body().close() } | ||||
|                 .doOnNext { if (!it.isSuccessful) throw Exception("Could not add manga") } | ||||
|                 .doOnError { Timber.e(it) } | ||||
|                 .map { track } | ||||
|     } | ||||
|  | ||||
|     override fun update(track: Track): Observable<Track> { | ||||
|         if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { | ||||
|             track.status = COMPLETED | ||||
|         } | ||||
|         return api.updateManga(track.remote_id, track.last_chapter_read, track.getAnilistStatus(), | ||||
|                 track.getAnilistScore()) | ||||
|                 .doOnNext { it.body().close() } | ||||
|                 .doOnNext { if (!it.isSuccessful) throw Exception("Could not update manga") } | ||||
|                 .doOnError { Timber.e(it) } | ||||
|                 .map { 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 } | ||||
|                     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 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") | ||||
|                     } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun getStatusList(): List<Int> { | ||||
|         return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) | ||||
|     } | ||||
|  | ||||
|     override fun getStatus(status: Int): String = with(context) { | ||||
|         when (status) { | ||||
|             READING -> getString(R.string.reading) | ||||
|             COMPLETED -> getString(R.string.completed) | ||||
|             ON_HOLD -> getString(R.string.on_hold) | ||||
|             DROPPED -> getString(R.string.dropped) | ||||
|             PLAN_TO_READ -> getString(R.string.plan_to_read) | ||||
|             else -> "" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun Track.getAnilistStatus() = when (status) { | ||||
|         READING -> "reading" | ||||
|         COMPLETED -> "completed" | ||||
|         ON_HOLD -> "on-hold" | ||||
|         DROPPED -> "dropped" | ||||
|         PLAN_TO_READ -> "plan to read" | ||||
|         else -> throw NotImplementedError("Unknown status") | ||||
|     } | ||||
|  | ||||
|     fun Track.getAnilistScore(): String = when (preferences.anilistScoreType().getOrDefault()) { | ||||
|         // 10 point | ||||
|         0 -> Math.floor(score.toDouble() / 10).toInt().toString() | ||||
|         // 100 point | ||||
|         1 -> score.toInt().toString() | ||||
|         // 5 stars | ||||
|         2 -> when { | ||||
|             score == 0f -> "0" | ||||
|             score < 30 -> "1" | ||||
|             score < 50 -> "2" | ||||
|             score < 70 -> "3" | ||||
|             score < 90 -> "4" | ||||
|             else -> "5" | ||||
|         } | ||||
|         // Smiley | ||||
|         3 -> when { | ||||
|             score == 0f -> "0" | ||||
|             score <= 30 -> ":(" | ||||
|             score <= 60 -> ":|" | ||||
|             else -> ":)" | ||||
|         } | ||||
|         // 10 point decimal | ||||
|         4 -> (score / 10).toString() | ||||
|         else -> throw Exception("Unknown score type") | ||||
|     } | ||||
|  | ||||
|     override fun formatScore(track: Track): String { | ||||
|         return track.getAnilistScore() | ||||
|     } | ||||
|  | ||||
| } | ||||
|  | ||||
| @@ -1,89 +1,88 @@ | ||||
| package eu.kanade.tachiyomi.data.mangasync.anilist | ||||
| 
 | ||||
| import android.net.Uri | ||||
| import com.google.gson.JsonObject | ||||
| import eu.kanade.tachiyomi.data.mangasync.anilist.model.ALManga | ||||
| import eu.kanade.tachiyomi.data.mangasync.anilist.model.ALUserLists | ||||
| import eu.kanade.tachiyomi.data.mangasync.anilist.model.OAuth | ||||
| import eu.kanade.tachiyomi.data.network.POST | ||||
| import okhttp3.FormBody | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.ResponseBody | ||||
| import retrofit2.Response | ||||
| import retrofit2.Retrofit | ||||
| import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory | ||||
| import retrofit2.converter.gson.GsonConverterFactory | ||||
| import retrofit2.http.* | ||||
| import rx.Observable | ||||
| 
 | ||||
| interface AnilistApi { | ||||
| 
 | ||||
|     companion object { | ||||
|         private const val clientId = "tachiyomi-hrtje" | ||||
|         private const val clientSecret = "nlGB5OmgE9YWq5dr3gIDbTQV0C" | ||||
|         private const val clientUrl = "tachiyomi://anilist-auth" | ||||
|         private const val baseUrl = "https://anilist.co/api/" | ||||
| 
 | ||||
|         fun authUrl() = Uri.parse("${baseUrl}auth/authorize").buildUpon() | ||||
|                 .appendQueryParameter("grant_type", "authorization_code") | ||||
|                 .appendQueryParameter("client_id", clientId) | ||||
|                 .appendQueryParameter("redirect_uri", clientUrl) | ||||
|                 .appendQueryParameter("response_type", "code") | ||||
|                 .build() | ||||
| 
 | ||||
|         fun refreshTokenRequest(token: String) = POST("${baseUrl}auth/access_token", | ||||
|                 body = FormBody.Builder() | ||||
|                         .add("grant_type", "refresh_token") | ||||
|                         .add("client_id", clientId) | ||||
|                         .add("client_secret", clientSecret) | ||||
|                         .add("refresh_token", token) | ||||
|                         .build()) | ||||
| 
 | ||||
|         fun createService(client: OkHttpClient) = Retrofit.Builder() | ||||
|                 .baseUrl(baseUrl) | ||||
|                 .client(client) | ||||
|                 .addConverterFactory(GsonConverterFactory.create()) | ||||
|                 .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) | ||||
|                 .build() | ||||
|                 .create(AnilistApi::class.java) | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     @FormUrlEncoded | ||||
|     @POST("auth/access_token") | ||||
|     fun requestAccessToken( | ||||
|             @Field("code") code: String, | ||||
|             @Field("grant_type") grant_type: String = "authorization_code", | ||||
|             @Field("client_id") client_id: String = clientId, | ||||
|             @Field("client_secret") client_secret: String = clientSecret, | ||||
|             @Field("redirect_uri") redirect_uri: String = clientUrl) | ||||
|             : Observable<OAuth> | ||||
| 
 | ||||
|     @GET("user") | ||||
|     fun getCurrentUser(): Observable<JsonObject> | ||||
| 
 | ||||
|     @GET("manga/search/{query}") | ||||
|     fun search(@Path("query") query: String, @Query("page") page: Int): Observable<List<ALManga>> | ||||
| 
 | ||||
|     @GET("user/{username}/mangalist") | ||||
|     fun getList(@Path("username") username: String): Observable<ALUserLists> | ||||
| 
 | ||||
|     @FormUrlEncoded | ||||
|     @PUT("mangalist") | ||||
|     fun addManga( | ||||
|             @Field("id") id: Int, | ||||
|             @Field("chapters_read") chapters_read: Int, | ||||
|             @Field("list_status") list_status: String, | ||||
|             @Field("score_raw") score_raw: Int) | ||||
|             : Observable<Response<ResponseBody>> | ||||
| 
 | ||||
|     @FormUrlEncoded | ||||
|     @PUT("mangalist") | ||||
|     fun updateManga( | ||||
|             @Field("id") id: Int, | ||||
|             @Field("chapters_read") chapters_read: Int, | ||||
|             @Field("list_status") list_status: String, | ||||
|             @Field("score_raw") score_raw: Int) | ||||
|             : Observable<Response<ResponseBody>> | ||||
| 
 | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
| 
 | ||||
| import android.net.Uri | ||||
| import com.google.gson.JsonObject | ||||
| import eu.kanade.tachiyomi.data.network.POST | ||||
| import eu.kanade.tachiyomi.data.track.anilist.model.ALManga | ||||
| import eu.kanade.tachiyomi.data.track.anilist.model.ALUserLists | ||||
| import eu.kanade.tachiyomi.data.track.anilist.model.OAuth | ||||
| import okhttp3.FormBody | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.ResponseBody | ||||
| import retrofit2.Response | ||||
| import retrofit2.Retrofit | ||||
| import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory | ||||
| import retrofit2.converter.gson.GsonConverterFactory | ||||
| import retrofit2.http.* | ||||
| import rx.Observable | ||||
| 
 | ||||
| interface AnilistApi { | ||||
| 
 | ||||
|     companion object { | ||||
|         private const val clientId = "tachiyomi-hrtje" | ||||
|         private const val clientSecret = "nlGB5OmgE9YWq5dr3gIDbTQV0C" | ||||
|         private const val clientUrl = "tachiyomi://anilist-auth" | ||||
|         private const val baseUrl = "https://anilist.co/api/" | ||||
| 
 | ||||
|         fun authUrl() = Uri.parse("${baseUrl}auth/authorize").buildUpon() | ||||
|                 .appendQueryParameter("grant_type", "authorization_code") | ||||
|                 .appendQueryParameter("client_id", clientId) | ||||
|                 .appendQueryParameter("redirect_uri", clientUrl) | ||||
|                 .appendQueryParameter("response_type", "code") | ||||
|                 .build() | ||||
| 
 | ||||
|         fun refreshTokenRequest(token: String) = POST("${baseUrl}auth/access_token", | ||||
|                 body = FormBody.Builder() | ||||
|                         .add("grant_type", "refresh_token") | ||||
|                         .add("client_id", clientId) | ||||
|                         .add("client_secret", clientSecret) | ||||
|                         .add("refresh_token", token) | ||||
|                         .build()) | ||||
| 
 | ||||
|         fun createService(client: OkHttpClient) = Retrofit.Builder() | ||||
|                 .baseUrl(baseUrl) | ||||
|                 .client(client) | ||||
|                 .addConverterFactory(GsonConverterFactory.create()) | ||||
|                 .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) | ||||
|                 .build() | ||||
|                 .create(AnilistApi::class.java) | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     @FormUrlEncoded | ||||
|     @POST("auth/access_token") | ||||
|     fun requestAccessToken( | ||||
|             @Field("code") code: String, | ||||
|             @Field("grant_type") grant_type: String = "authorization_code", | ||||
|             @Field("client_id") client_id: String = clientId, | ||||
|             @Field("client_secret") client_secret: String = clientSecret, | ||||
|             @Field("redirect_uri") redirect_uri: String = clientUrl) | ||||
|             : Observable<OAuth> | ||||
| 
 | ||||
|     @GET("user") | ||||
|     fun getCurrentUser(): Observable<JsonObject> | ||||
| 
 | ||||
|     @GET("manga/search/{query}") | ||||
|     fun search(@Path("query") query: String, @Query("page") page: Int): Observable<List<ALManga>> | ||||
| 
 | ||||
|     @GET("user/{username}/mangalist") | ||||
|     fun getList(@Path("username") username: String): Observable<ALUserLists> | ||||
| 
 | ||||
|     @FormUrlEncoded | ||||
|     @PUT("mangalist") | ||||
|     fun addManga( | ||||
|             @Field("id") id: Int, | ||||
|             @Field("chapters_read") chapters_read: Int, | ||||
|             @Field("list_status") list_status: String) | ||||
|             : Observable<Response<ResponseBody>> | ||||
| 
 | ||||
|     @FormUrlEncoded | ||||
|     @PUT("mangalist") | ||||
|     fun updateManga( | ||||
|             @Field("id") id: Int, | ||||
|             @Field("chapters_read") chapters_read: Int, | ||||
|             @Field("list_status") list_status: String, | ||||
|             @Field("score") score_raw: String) | ||||
|             : Observable<Response<ResponseBody>> | ||||
| 
 | ||||
| } | ||||
| @@ -1,61 +1,61 @@ | ||||
| package eu.kanade.tachiyomi.data.mangasync.anilist | ||||
| 
 | ||||
| import com.google.gson.Gson | ||||
| import eu.kanade.tachiyomi.data.mangasync.anilist.model.OAuth | ||||
| import okhttp3.Interceptor | ||||
| import okhttp3.Response | ||||
| 
 | ||||
| class AnilistInterceptor(private var refreshToken: String?) : Interceptor { | ||||
| 
 | ||||
|     /** | ||||
|      * OAuth object used for authenticated requests. | ||||
|      * | ||||
|      * Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute | ||||
|      * before its original expiration date. | ||||
|      */ | ||||
|     private var oauth: OAuth? = null | ||||
|         set(value) { | ||||
|             field = value?.copy(expires = value.expires * 1000 - 60 * 1000) | ||||
|         } | ||||
| 
 | ||||
|     override fun intercept(chain: Interceptor.Chain): Response { | ||||
|         val originalRequest = chain.request() | ||||
| 
 | ||||
|         if (refreshToken.isNullOrEmpty()) { | ||||
|             throw Exception("Not authenticated with Anilist") | ||||
|         } | ||||
| 
 | ||||
|         // Refresh access token if null or expired. | ||||
|         if (oauth == null || oauth!!.isExpired()) { | ||||
|             val response = chain.proceed(AnilistApi.refreshTokenRequest(refreshToken!!)) | ||||
|             oauth = if (response.isSuccessful) { | ||||
|                 Gson().fromJson(response.body().string(), OAuth::class.java) | ||||
|             } else { | ||||
|                 response.close() | ||||
|                 null | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // Throw on null auth. | ||||
|         if (oauth == null) { | ||||
|             throw Exception("Access token wasn't refreshed") | ||||
|         } | ||||
| 
 | ||||
|         // Add the authorization header to the original request. | ||||
|         val authRequest = originalRequest.newBuilder() | ||||
|                 .addHeader("Authorization", "Bearer ${oauth!!.access_token}") | ||||
|                 .build() | ||||
| 
 | ||||
|         return chain.proceed(authRequest) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Called when the user authenticates with Anilist for the first time. Sets the refresh token | ||||
|      * and the oauth object. | ||||
|      */ | ||||
|     fun setAuth(oauth: OAuth?) { | ||||
|         refreshToken = oauth?.refresh_token | ||||
|         this.oauth = oauth | ||||
|     } | ||||
| 
 | ||||
| package eu.kanade.tachiyomi.data.track.anilist | ||||
| 
 | ||||
| import com.google.gson.Gson | ||||
| import eu.kanade.tachiyomi.data.track.anilist.model.OAuth | ||||
| import okhttp3.Interceptor | ||||
| import okhttp3.Response | ||||
| 
 | ||||
| class AnilistInterceptor(private var refreshToken: String?) : Interceptor { | ||||
| 
 | ||||
|     /** | ||||
|      * OAuth object used for authenticated requests. | ||||
|      * | ||||
|      * Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute | ||||
|      * before its original expiration date. | ||||
|      */ | ||||
|     private var oauth: OAuth? = null | ||||
|         set(value) { | ||||
|             field = value?.copy(expires = value.expires * 1000 - 60 * 1000) | ||||
|         } | ||||
| 
 | ||||
|     override fun intercept(chain: Interceptor.Chain): Response { | ||||
|         val originalRequest = chain.request() | ||||
| 
 | ||||
|         if (refreshToken.isNullOrEmpty()) { | ||||
|             throw Exception("Not authenticated with Anilist") | ||||
|         } | ||||
| 
 | ||||
|         // Refresh access token if null or expired. | ||||
|         if (oauth == null || oauth!!.isExpired()) { | ||||
|             val response = chain.proceed(AnilistApi.refreshTokenRequest(refreshToken!!)) | ||||
|             oauth = if (response.isSuccessful) { | ||||
|                 Gson().fromJson(response.body().string(), OAuth::class.java) | ||||
|             } else { | ||||
|                 response.close() | ||||
|                 null | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         // Throw on null auth. | ||||
|         if (oauth == null) { | ||||
|             throw Exception("Access token wasn't refreshed") | ||||
|         } | ||||
| 
 | ||||
|         // Add the authorization header to the original request. | ||||
|         val authRequest = originalRequest.newBuilder() | ||||
|                 .addHeader("Authorization", "Bearer ${oauth!!.access_token}") | ||||
|                 .build() | ||||
| 
 | ||||
|         return chain.proceed(authRequest) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Called when the user authenticates with Anilist for the first time. Sets the refresh token | ||||
|      * and the oauth object. | ||||
|      */ | ||||
|     fun setAuth(oauth: OAuth?) { | ||||
|         refreshToken = oauth?.refresh_token | ||||
|         this.oauth = oauth | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @@ -1,17 +1,17 @@ | ||||
| package eu.kanade.tachiyomi.data.mangasync.anilist.model | ||||
| 
 | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaSync | ||||
| import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager | ||||
| 
 | ||||
| data class ALManga( | ||||
|         val id: Int, | ||||
|         val title_romaji: String, | ||||
|         val type: String, | ||||
|         val total_chapters: Int) { | ||||
| 
 | ||||
|     fun toMangaSync() = MangaSync.create(MangaSyncManager.ANILIST).apply { | ||||
|         remote_id = this@ALManga.id | ||||
|         title = title_romaji | ||||
|         total_chapters = this@ALManga.total_chapters | ||||
|     } | ||||
| package eu.kanade.tachiyomi.data.track.anilist.model | ||||
| 
 | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| 
 | ||||
| data class ALManga( | ||||
|         val id: Int, | ||||
|         val title_romaji: String, | ||||
|         val type: String, | ||||
|         val total_chapters: Int) { | ||||
| 
 | ||||
|     fun toTrack() = Track.create(TrackManager.ANILIST).apply { | ||||
|         remote_id = this@ALManga.id | ||||
|         title = title_romaji | ||||
|         total_chapters = this@ALManga.total_chapters | ||||
|     } | ||||
| } | ||||
| @@ -1,6 +1,6 @@ | ||||
| package eu.kanade.tachiyomi.data.mangasync.anilist.model | ||||
| 
 | ||||
| data class ALUserLists(val lists: Map<String, List<ALUserManga>>) { | ||||
| 
 | ||||
|     fun flatten() = lists.values.flatten() | ||||
| package eu.kanade.tachiyomi.data.track.anilist.model | ||||
| 
 | ||||
| data class ALUserLists(val lists: Map<String, List<ALUserManga>>) { | ||||
| 
 | ||||
|     fun flatten() = lists.values.flatten() | ||||
| } | ||||
| @@ -1,29 +1,29 @@ | ||||
| package eu.kanade.tachiyomi.data.mangasync.anilist.model | ||||
| 
 | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaSync | ||||
| import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager | ||||
| import eu.kanade.tachiyomi.data.mangasync.anilist.Anilist | ||||
| 
 | ||||
| data class ALUserManga( | ||||
|         val id: Int, | ||||
|         val list_status: String, | ||||
|         val score_raw: Int, | ||||
|         val chapters_read: Int, | ||||
|         val manga: ALManga) { | ||||
| 
 | ||||
|     fun toMangaSync() = MangaSync.create(MangaSyncManager.ANILIST).apply { | ||||
|         remote_id = manga.id | ||||
|         status = getMangaSyncStatus() | ||||
|         score = score_raw.toFloat() | ||||
|         last_chapter_read = chapters_read | ||||
|     } | ||||
| 
 | ||||
|     fun getMangaSyncStatus() = when (list_status) { | ||||
|         "reading" -> Anilist.READING | ||||
|         "completed" -> Anilist.COMPLETED | ||||
|         "on-hold" -> Anilist.ON_HOLD | ||||
|         "dropped" -> Anilist.DROPPED | ||||
|         "plan to read" -> Anilist.PLAN_TO_READ | ||||
|         else -> throw NotImplementedError("Unknown status") | ||||
|     } | ||||
| package eu.kanade.tachiyomi.data.track.anilist.model | ||||
| 
 | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
| import eu.kanade.tachiyomi.data.track.anilist.Anilist | ||||
| 
 | ||||
| data class ALUserManga( | ||||
|         val id: Int, | ||||
|         val list_status: String, | ||||
|         val score_raw: Int, | ||||
|         val chapters_read: Int, | ||||
|         val manga: ALManga) { | ||||
| 
 | ||||
|     fun toTrack() = Track.create(TrackManager.ANILIST).apply { | ||||
|         remote_id = manga.id | ||||
|         status = toTrackStatus() | ||||
|         score = score_raw.toFloat() | ||||
|         last_chapter_read = chapters_read | ||||
|     } | ||||
| 
 | ||||
|     fun toTrackStatus() = when (list_status) { | ||||
|         "reading" -> Anilist.READING | ||||
|         "completed" -> Anilist.COMPLETED | ||||
|         "on-hold" -> Anilist.ON_HOLD | ||||
|         "dropped" -> Anilist.DROPPED | ||||
|         "plan to read" -> Anilist.PLAN_TO_READ | ||||
|         else -> throw NotImplementedError("Unknown status") | ||||
|     } | ||||
| } | ||||
| @@ -1,11 +1,11 @@ | ||||
| package eu.kanade.tachiyomi.data.mangasync.anilist.model | ||||
| 
 | ||||
| data class OAuth( | ||||
|         val access_token: String, | ||||
|         val token_type: String, | ||||
|         val expires: Long, | ||||
|         val expires_in: Long, | ||||
|         val refresh_token: String?) { | ||||
| 
 | ||||
|     fun isExpired() = System.currentTimeMillis() > expires | ||||
| package eu.kanade.tachiyomi.data.track.anilist.model | ||||
| 
 | ||||
| data class OAuth( | ||||
|         val access_token: String, | ||||
|         val token_type: String, | ||||
|         val expires: Long, | ||||
|         val expires_in: Long, | ||||
|         val refresh_token: String?) { | ||||
| 
 | ||||
|     fun isExpired() = System.currentTimeMillis() > expires | ||||
| } | ||||
							
								
								
									
										219
									
								
								app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										219
									
								
								app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,219 @@ | ||||
| package eu.kanade.tachiyomi.data.track.kitsu | ||||
|  | ||||
| import android.content.Context | ||||
| import android.graphics.Color | ||||
| import com.github.salomonbrys.kotson.* | ||||
| import com.google.gson.Gson | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackService | ||||
| import rx.Completable | ||||
| import rx.Observable | ||||
| import timber.log.Timber | ||||
| import uy.kohesive.injekt.injectLazy | ||||
|  | ||||
| class Kitsu(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 = 5 | ||||
|  | ||||
|         const val DEFAULT_STATUS = READING | ||||
|         const val DEFAULT_SCORE = 0f | ||||
|     } | ||||
|  | ||||
|     override val name = "Kitsu" | ||||
|  | ||||
|     private val gson: Gson by injectLazy() | ||||
|  | ||||
|     private val interceptor by lazy { KitsuInterceptor(this, gson) } | ||||
|  | ||||
|     private val api by lazy { | ||||
|         KitsuApi.createService(client.newBuilder() | ||||
|                 .addInterceptor(interceptor) | ||||
|                 .build()) | ||||
|     } | ||||
|  | ||||
|     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 | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun login(username: String, password: String): Completable { | ||||
|         return KitsuApi.createLoginService(client) | ||||
|                 .requestAccessToken(username, password) | ||||
|                 .doOnNext { interceptor.newAuth(it) } | ||||
|                 .flatMap { api.getCurrentUser().map { it["data"].array[0]["id"].string } } | ||||
|                 .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) | ||||
|                 .map { json -> | ||||
|                     val data = json["data"].array | ||||
|                     data.map { KitsuManga(it.obj).toTrack() } | ||||
|                 } | ||||
|                 .doOnError { Timber.e(it) } | ||||
|     } | ||||
|  | ||||
|     override fun bind(track: Track): Observable<Track> { | ||||
|         return find(track) | ||||
|                 .flatMap { remoteTrack -> | ||||
|                     if (remoteTrack != null) { | ||||
|                         track.copyPersonalFrom(remoteTrack) | ||||
|                         track.remote_id = remoteTrack.remote_id | ||||
|                         update(track) | ||||
|                     } else { | ||||
|                         track.score = DEFAULT_SCORE | ||||
|                         track.status = DEFAULT_STATUS | ||||
|                         add(track) | ||||
|                     } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     private fun find(track: Track): Observable<Track?> { | ||||
|         return api.findLibManga(getUserId(), track.remote_id) | ||||
|                 .map { json -> | ||||
|                     val data = json["data"].array | ||||
|                     if (data.size() > 0) { | ||||
|                         KitsuLibManga(data[0].obj, json["included"].array[0].obj).toTrack() | ||||
|                     } else { | ||||
|                         null | ||||
|                     } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun add(track: Track): Observable<Track> { | ||||
|         // @formatter:off | ||||
|         val data = jsonObject( | ||||
|             "type" to "libraryEntries", | ||||
|             "attributes" to jsonObject( | ||||
|                 "status" to track.getKitsuStatus(), | ||||
|                 "progress" to track.last_chapter_read | ||||
|             ), | ||||
|             "relationships" to jsonObject( | ||||
|                 "user" to jsonObject( | ||||
|                     "data" to jsonObject( | ||||
|                         "id" to getUserId(), | ||||
|                         "type" to "users" | ||||
|                     ) | ||||
|                 ), | ||||
|                 "media" to jsonObject( | ||||
|                     "data" to jsonObject( | ||||
|                         "id" to track.remote_id, | ||||
|                         "type" to "manga" | ||||
|                     ) | ||||
|                 ) | ||||
|             ) | ||||
|         ) | ||||
|         // @formatter:on | ||||
|  | ||||
|         return api.addLibManga(jsonObject("data" to data)) | ||||
|                 .doOnNext { json -> track.remote_id = json["data"]["id"].int } | ||||
|                 .doOnError { Timber.e(it) } | ||||
|                 .map { track } | ||||
|     } | ||||
|  | ||||
|     override fun update(track: Track): Observable<Track> { | ||||
|         if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { | ||||
|             track.status = COMPLETED | ||||
|         } | ||||
|         // @formatter:off | ||||
|         val data = jsonObject( | ||||
|             "type" to "libraryEntries", | ||||
|             "id" to track.remote_id, | ||||
|             "attributes" to jsonObject( | ||||
|                 "status" to track.getKitsuStatus(), | ||||
|                 "progress" to track.last_chapter_read, | ||||
|                 "rating" to track.getKitsuScore() | ||||
|             ) | ||||
|         ) | ||||
|         // @formatter:on | ||||
|  | ||||
|         return api.updateLibManga(track.remote_id, jsonObject("data" to data)) | ||||
|                 .map { track } | ||||
|     } | ||||
|  | ||||
|     override fun refresh(track: Track): Observable<Track> { | ||||
|         return api.getLibManga(track.remote_id) | ||||
|                 .map { json -> | ||||
|                     val data = json["data"].array | ||||
|                     if (data.size() > 0) { | ||||
|                         val include = json["included"].array[0].obj | ||||
|                         val remoteTrack = KitsuLibManga(data[0].obj, include).toTrack() | ||||
|                         track.copyPersonalFrom(remoteTrack) | ||||
|                         track.total_chapters = remoteTrack.total_chapters | ||||
|                         track | ||||
|                     } else { | ||||
|                         throw Exception("Could not find manga") | ||||
|                     } | ||||
|                 } | ||||
|     } | ||||
|  | ||||
|     override fun getStatusList(): List<Int> { | ||||
|         return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) | ||||
|     } | ||||
|  | ||||
|     override fun getStatus(status: Int): String = with(context) { | ||||
|         when (status) { | ||||
|             READING -> getString(R.string.reading) | ||||
|             COMPLETED -> getString(R.string.completed) | ||||
|             ON_HOLD -> getString(R.string.on_hold) | ||||
|             DROPPED -> getString(R.string.dropped) | ||||
|             PLAN_TO_READ -> getString(R.string.plan_to_read) | ||||
|             else -> "" | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private fun Track.getKitsuStatus() = when (status) { | ||||
|         READING -> "current" | ||||
|         COMPLETED -> "completed" | ||||
|         ON_HOLD -> "on_hold" | ||||
|         DROPPED -> "dropped" | ||||
|         PLAN_TO_READ -> "planned" | ||||
|         else -> throw Exception("Unknown status") | ||||
|     } | ||||
|  | ||||
|     private fun Track.getKitsuScore(): String { | ||||
|         return if (score > 0) (score / 2).toString() else "" | ||||
|     } | ||||
|  | ||||
|     override fun getLogo(): Int { | ||||
|         return R.drawable.kitsu | ||||
|     } | ||||
|  | ||||
|     override fun getLogoColor(): Int { | ||||
|         return Color.rgb(51, 37, 50) | ||||
|     } | ||||
|  | ||||
|     override fun maxScore(): Int { | ||||
|         return 10 | ||||
|     } | ||||
|  | ||||
|     override fun formatScore(track: Track): String { | ||||
|         return track.getKitsuScore() | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,93 @@ | ||||
| package eu.kanade.tachiyomi.data.track.kitsu | ||||
|  | ||||
| import com.google.gson.JsonObject | ||||
| import eu.kanade.tachiyomi.data.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 | ||||
|  | ||||
| interface KitsuApi { | ||||
|  | ||||
|     companion object { | ||||
|         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/" | ||||
|  | ||||
|         fun createService(client: OkHttpClient) = Retrofit.Builder() | ||||
|                 .baseUrl(baseUrl) | ||||
|                 .client(client) | ||||
|                 .addConverterFactory(GsonConverterFactory.create()) | ||||
|                 .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) | ||||
|                 .build() | ||||
|                 .create(KitsuApi::class.java) | ||||
|  | ||||
|         fun createLoginService(client: OkHttpClient) = Retrofit.Builder() | ||||
|                 .baseUrl(loginUrl) | ||||
|                 .client(client) | ||||
|                 .addConverterFactory(GsonConverterFactory.create()) | ||||
|                 .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) | ||||
|                 .build() | ||||
|                 .create(KitsuApi::class.java) | ||||
|  | ||||
|         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()) | ||||
|     } | ||||
|  | ||||
|     @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> | ||||
|  | ||||
|     @GET("users") | ||||
|     fun getCurrentUser( | ||||
|             @Query("filter[self]", encoded = true) self: Boolean = true | ||||
|     ) : Observable<JsonObject> | ||||
|  | ||||
|     @GET("manga") | ||||
|     fun search( | ||||
|             @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("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 | ||||
|     ) : Observable<JsonObject> | ||||
|  | ||||
|     @Headers("Content-Type: application/vnd.api+json") | ||||
|     @PATCH("library-entries/{id}") | ||||
|     fun updateLibManga( | ||||
|             @Path("id") remoteId: Int, | ||||
|             @Body data: JsonObject | ||||
|     ) : Observable<JsonObject> | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,46 @@ | ||||
| package eu.kanade.tachiyomi.data.track.kitsu | ||||
|  | ||||
| import com.google.gson.Gson | ||||
| import okhttp3.Interceptor | ||||
| import okhttp3.Response | ||||
|  | ||||
| class KitsuInterceptor(val kitsu: Kitsu, val gson: Gson) : Interceptor { | ||||
|  | ||||
|     /** | ||||
|      * OAuth object used for authenticated requests. | ||||
|      */ | ||||
|     private var oauth: OAuth? = kitsu.restoreToken() | ||||
|  | ||||
|     override fun intercept(chain: Interceptor.Chain): Response { | ||||
|         val originalRequest = chain.request() | ||||
|  | ||||
|         val currAuth = oauth ?: throw Exception("Not authenticated with Kitsu") | ||||
|  | ||||
|         val refreshToken = currAuth.refresh_token!! | ||||
|  | ||||
|         // Refresh access token if expired. | ||||
|         if (currAuth.isExpired()) { | ||||
|             val response = chain.proceed(KitsuApi.refreshTokenRequest(refreshToken)) | ||||
|             if (response.isSuccessful) { | ||||
|                 newAuth(gson.fromJson(response.body().string(), OAuth::class.java)) | ||||
|             } else { | ||||
|                 response.close() | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Add the authorization header to the original request. | ||||
|         val authRequest = originalRequest.newBuilder() | ||||
|                 .addHeader("Authorization", "Bearer ${oauth!!.access_token}") | ||||
|                 .header("Accept", "application/vnd.api+json") | ||||
|                 .header("Content-Type", "application/vnd.api+json") | ||||
|                 .build() | ||||
|  | ||||
|         return chain.proceed(authRequest) | ||||
|     } | ||||
|  | ||||
|     fun newAuth(oauth: OAuth?) { | ||||
|         this.oauth = oauth | ||||
|         kitsu.saveToken(oauth) | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,44 @@ | ||||
| package eu.kanade.tachiyomi.data.track.kitsu | ||||
|  | ||||
| import android.support.annotation.CallSuper | ||||
| import com.github.salomonbrys.kotson.* | ||||
| import com.google.gson.JsonObject | ||||
| import eu.kanade.tachiyomi.data.database.models.Track | ||||
| import eu.kanade.tachiyomi.data.track.TrackManager | ||||
|  | ||||
| open class KitsuManga(obj: JsonObject) { | ||||
|     val id by obj.byInt | ||||
|     val canonicalTitle by obj["attributes"].byString | ||||
|     val chapterCount = obj["attributes"]["chapterCount"].nullInt | ||||
|  | ||||
|     @CallSuper | ||||
|     open fun toTrack() = Track.create(TrackManager.KITSU).apply { | ||||
|         remote_id = this@KitsuManga.id | ||||
|         title = canonicalTitle | ||||
|         total_chapters = chapterCount ?: 0 | ||||
|     } | ||||
| } | ||||
|  | ||||
| class KitsuLibManga(obj: JsonObject, manga: JsonObject) : KitsuManga(manga) { | ||||
|     val remoteId by obj.byInt("id") | ||||
|     val status by obj["attributes"].byString | ||||
|     val rating = obj["attributes"]["rating"].nullString | ||||
|     val progress by obj["attributes"].byInt | ||||
|  | ||||
|     override fun toTrack() = super.toTrack().apply { | ||||
|         remote_id = remoteId | ||||
|         status = toTrackStatus() | ||||
|         score = rating?.let { it.toFloat() * 2 } ?: 0f | ||||
|         last_chapter_read = progress | ||||
|     } | ||||
|  | ||||
|     private fun toTrackStatus() = when (status) { | ||||
|         "current" -> Kitsu.READING | ||||
|         "completed" -> Kitsu.COMPLETED | ||||
|         "on_hold" -> Kitsu.ON_HOLD | ||||
|         "dropped" -> Kitsu.DROPPED | ||||
|         "planned" -> Kitsu.PLAN_TO_READ | ||||
|         else -> throw Exception("Unknown status") | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -0,0 +1,11 @@ | ||||
| package eu.kanade.tachiyomi.data.track.kitsu | ||||
|  | ||||
| data class OAuth( | ||||
|         val access_token: String, | ||||
|         val token_type: String, | ||||
|         val created_at: Long, | ||||
|         val expires_in: Long, | ||||
|         val refresh_token: String?) { | ||||
|  | ||||
|     fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) | ||||
| } | ||||
| @@ -1,222 +1,263 @@ | ||||
| package eu.kanade.tachiyomi.data.mangasync.myanimelist | ||||
| 
 | ||||
| import android.content.Context | ||||
| import android.net.Uri | ||||
| import android.util.Xml | ||||
| import eu.kanade.tachiyomi.R | ||||
| import eu.kanade.tachiyomi.data.database.models.MangaSync | ||||
| import eu.kanade.tachiyomi.data.mangasync.MangaSyncService | ||||
| 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.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) : MangaSyncService(context, id) { | ||||
| 
 | ||||
|     private lateinit var headers: Headers | ||||
| 
 | ||||
|     companion object { | ||||
|         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" | ||||
| 
 | ||||
|         val READING = 1 | ||||
|         val COMPLETED = 2 | ||||
|         val ON_HOLD = 3 | ||||
|         val DROPPED = 4 | ||||
|         val PLAN_TO_READ = 6 | ||||
| 
 | ||||
|         val DEFAULT_STATUS = READING | ||||
|         val DEFAULT_SCORE = 0 | ||||
|     } | ||||
| 
 | ||||
|     init { | ||||
|         val username = getUsername() | ||||
|         val password = getPassword() | ||||
| 
 | ||||
|         if (!username.isEmpty() && !password.isEmpty()) { | ||||
|             createHeaders(username, password) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override val name: String | ||||
|         get() = "MyAnimeList" | ||||
| 
 | ||||
|     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(manga: MangaSync) = Uri.parse(BASE_URL).buildUpon() | ||||
|             .appendEncodedPath("api/mangalist/update") | ||||
|             .appendPath("${manga.remote_id}.xml") | ||||
|             .toString() | ||||
| 
 | ||||
|     fun getAddUrl(manga: MangaSync) = Uri.parse(BASE_URL).buildUpon() | ||||
|             .appendEncodedPath("api/mangalist/add") | ||||
|             .appendPath("${manga.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") } | ||||
|                 .toCompletable() | ||||
|     } | ||||
| 
 | ||||
|     fun search(query: String): Observable<List<MangaSync>> { | ||||
|         return 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 { | ||||
|                     MangaSync.create(id).apply { | ||||
|                         title = it.selectText("title")!! | ||||
|                         remote_id = it.selectInt("id") | ||||
|                         total_chapters = it.selectInt("chapters") | ||||
|                     } | ||||
|                 } | ||||
|                 .toList() | ||||
|     } | ||||
| 
 | ||||
|     // MAL doesn't support score with decimals | ||||
|     fun getList(): Observable<List<MangaSync>> { | ||||
|         return networkService.forceCacheClient | ||||
|                 .newCall(GET(getListUrl(getUsername()), headers)) | ||||
|                 .asObservable() | ||||
|                 .map { Jsoup.parse(it.body().string()) } | ||||
|                 .flatMap { Observable.from(it.select("manga")) } | ||||
|                 .map { | ||||
|                     MangaSync.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(manga: MangaSync): Observable<MangaSync> { | ||||
|         return Observable.defer { | ||||
|             if (manga.total_chapters != 0 && manga.last_chapter_read == manga.total_chapters) { | ||||
|                 manga.status = COMPLETED | ||||
|             } | ||||
|             client.newCall(POST(getUpdateUrl(manga), headers, getMangaPostPayload(manga))) | ||||
|                     .asObservable() | ||||
|                     .doOnNext { it.close() } | ||||
|                     .doOnNext { if (!it.isSuccessful) throw Exception("Could not update manga") } | ||||
|                     .map { manga } | ||||
|         } | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     override fun add(manga: MangaSync): Observable<MangaSync> { | ||||
|         return Observable.defer { | ||||
|             client.newCall(POST(getAddUrl(manga), headers, getMangaPostPayload(manga))) | ||||
|                     .asObservable() | ||||
|                     .doOnNext { it.close() } | ||||
|                     .doOnNext { if (!it.isSuccessful) throw Exception("Could not add manga") } | ||||
|                     .map { manga } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private fun getMangaPostPayload(manga: MangaSync): RequestBody { | ||||
|         val xml = Xml.newSerializer() | ||||
|         val writer = StringWriter() | ||||
| 
 | ||||
|         with(xml) { | ||||
|             setOutput(writer) | ||||
|             startDocument("UTF-8", false) | ||||
|             startTag("", ENTRY_TAG) | ||||
| 
 | ||||
|             // Last chapter read | ||||
|             if (manga.last_chapter_read != 0) { | ||||
|                 inTag(CHAPTER_TAG, manga.last_chapter_read.toString()) | ||||
|             } | ||||
|             // Manga status in the list | ||||
|             inTag(STATUS_TAG, manga.status.toString()) | ||||
| 
 | ||||
|             // Manga score | ||||
|             inTag(SCORE_TAG, manga.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) | ||||
|     } | ||||
| 
 | ||||
|     override fun bind(manga: MangaSync): Observable<MangaSync> { | ||||
|         return getList() | ||||
|                 .flatMap { userlist -> | ||||
|                     manga.sync_id = id | ||||
|                     val mangaFromList = userlist.find { it.remote_id == manga.remote_id } | ||||
|                     if (mangaFromList != null) { | ||||
|                         manga.copyPersonalFrom(mangaFromList) | ||||
|                         update(manga) | ||||
|                     } else { | ||||
|                         // Set default fields if it's not found in the list | ||||
|                         manga.score = DEFAULT_SCORE.toFloat() | ||||
|                         manga.status = DEFAULT_STATUS | ||||
|                         add(manga) | ||||
|                     } | ||||
|                 } | ||||
|     } | ||||
| 
 | ||||
|     override fun getStatus(status: Int): String = with(context) { | ||||
|         when (status) { | ||||
|             READING -> getString(R.string.reading) | ||||
|             COMPLETED -> getString(R.string.completed) | ||||
|             ON_HOLD -> getString(R.string.on_hold) | ||||
|             DROPPED -> getString(R.string.dropped) | ||||
|             PLAN_TO_READ -> getString(R.string.plan_to_read) | ||||
|             else -> "" | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     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() | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 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 | ||||
| 
 | ||||
|     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 | ||||
|         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 PREFIX_MY = "my:" | ||||
|     } | ||||
| 
 | ||||
|     init { | ||||
|         val username = getUsername() | ||||
|         val password = getPassword() | ||||
| 
 | ||||
|         if (!username.isEmpty() && !password.isEmpty()) { | ||||
|             createHeaders(username, password) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override val name: String | ||||
|         get() = "MyAnimeList" | ||||
| 
 | ||||
|     override fun getLogo() = R.drawable.mal | ||||
| 
 | ||||
|     override fun getLogoColor() = Color.rgb(46, 81, 162) | ||||
| 
 | ||||
|     override fun maxScore() = 10 | ||||
| 
 | ||||
|     override fun formatScore(track: Track): String { | ||||
|         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 } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     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) | ||||
|     } | ||||
| 
 | ||||
|     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 } | ||||
|                     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 fun getStatus(status: Int): String = with(context) { | ||||
|         when (status) { | ||||
|             READING -> getString(R.string.reading) | ||||
|             COMPLETED -> getString(R.string.completed) | ||||
|             ON_HOLD -> getString(R.string.on_hold) | ||||
|             DROPPED -> getString(R.string.dropped) | ||||
|             PLAN_TO_READ -> getString(R.string.plan_to_read) | ||||
|             else -> "" | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override fun getStatusList(): List<Int> { | ||||
|         return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLAN_TO_READ) | ||||
|     } | ||||
| 
 | ||||
|     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() | ||||
|     } | ||||
| 
 | ||||
| } | ||||
		Reference in New Issue
	
	Block a user