Update Mangadex

This commit is contained in:
Jobobby04 2021-07-05 18:31:30 -04:00
parent efba76380a
commit 20d8cf6c10
36 changed files with 943 additions and 1200 deletions

View File

@ -21,8 +21,9 @@ import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.RandomMangaSource
import eu.kanade.tachiyomi.ui.base.controller.BaseController
import eu.kanade.tachiyomi.ui.manga.MangaController
import eu.kanade.tachiyomi.util.lang.runAsObservable
import exh.md.MangaDexFabHeaderAdapter
import exh.md.handlers.ApiChapterParser
import exh.md.dto.MangaDto
import exh.md.handlers.ApiMangaParser
import exh.md.handlers.FollowsHandler
import exh.md.handlers.MangaHandler
@ -32,13 +33,14 @@ import exh.md.handlers.SimilarHandler
import exh.md.network.MangaDexLoginHelper
import exh.md.network.NoSessionException
import exh.md.network.TokenAuthenticator
import exh.md.service.MangaDexAuthService
import exh.md.service.MangaDexService
import exh.md.service.SimilarService
import exh.md.utils.FollowStatus
import exh.md.utils.MdLang
import exh.md.utils.MdUtil
import exh.metadata.metadata.MangaDexSearchMetadata
import exh.source.DelegatedHttpSource
import exh.ui.metadata.adapters.MangaDexDescriptionAdapter
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Response
import rx.Observable
@ -51,7 +53,7 @@ import kotlin.reflect.KClass
@Suppress("OverridingDeprecatedMember")
class MangaDex(delegate: HttpSource, val context: Context) :
DelegatedHttpSource(delegate),
MetadataSource<MangaDexSearchMetadata, Response>,
MetadataSource<MangaDexSearchMetadata, MangaDto>,
// UrlImportableSource,
FollowsSource,
LoginSource,
@ -73,7 +75,9 @@ class MangaDex(delegate: HttpSource, val context: Context) :
context.getSharedPreferences("source_$id", 0x0000)
}
private val loginHelper = MangaDexLoginHelper(networkHttpClient, preferences, mdList)
val mangadexAuthServiceLazy = lazy { MangaDexAuthService(baseHttpClient, headers, preferences, mdList) }
private val loginHelper = MangaDexLoginHelper(mangadexAuthServiceLazy, preferences, mdList)
override val baseHttpClient: OkHttpClient = super.client.newBuilder()
.authenticator(
@ -84,26 +88,30 @@ class MangaDex(delegate: HttpSource, val context: Context) :
private fun dataSaver() = sourcePreferences.getBoolean(getDataSaverPreferenceKey(mdLang.lang), false)
private fun usePort443Only() = sourcePreferences.getBoolean(getStandardHttpsPreferenceKey(mdLang.lang), false)
private val apiMangaParser by lazy {
ApiMangaParser(baseHttpClient, mdLang.lang)
private val mangadexService by lazy {
MangaDexService(client)
}
private val apiChapterParser by lazy {
ApiChapterParser()
private val mangadexAuthService by mangadexAuthServiceLazy
private val similarService by lazy {
SimilarService(client)
}
private val apiMangaParser by lazy {
ApiMangaParser(mdLang.lang)
}
private val followsHandler by lazy {
FollowsHandler(baseHttpClient, headers, preferences, mdLang.lang, mdList)
FollowsHandler(mdLang.lang, mangadexAuthService)
}
private val mangaHandler by lazy {
MangaHandler(baseHttpClient, headers, mdLang.lang, apiMangaParser, followsHandler)
MangaHandler(mdLang.lang, mangadexService, apiMangaParser, followsHandler)
}
private val similarHandler by lazy {
SimilarHandler(baseHttpClient, mdLang.lang)
SimilarHandler(mdLang.lang, mangadexService, similarService)
}
private val mangaPlusHandler by lazy {
MangaPlusHandler(network.client)
}
private val pageHandler by lazy {
PageHandler(network.client, headers, apiChapterParser, mangaPlusHandler, preferences, mdList)
PageHandler(headers, mangadexService, mangaPlusHandler, preferences, mdList)
}
/*override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> =
@ -152,7 +160,7 @@ class MangaDex(delegate: HttpSource, val context: Context) :
}
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
return pageHandler.fetchPageList(chapter, isLogged(), usePort443Only(), dataSaver())
return runAsObservable({ pageHandler.fetchPageList(chapter, isLogged(), usePort443Only(), dataSaver()) })
}
override fun fetchImage(page: Page): Observable<Response> {
@ -168,7 +176,7 @@ class MangaDex(delegate: HttpSource, val context: Context) :
return MangaDexDescriptionAdapter(controller)
}
override suspend fun parseIntoMetadata(metadata: MangaDexSearchMetadata, input: Response) {
override suspend fun parseIntoMetadata(metadata: MangaDexSearchMetadata, input: MangaDto) {
apiMangaParser.parseIntoMetadata(metadata, input)
}
@ -198,8 +206,7 @@ class MangaDex(delegate: HttpSource, val context: Context) :
twoFactorCode: String?
): Boolean {
val result = loginHelper.login(username, password)
return if (result is MangaDexLoginHelper.LoginResult.Success) {
MdUtil.updateLoginToken(result.token, preferences, mdList)
return if (result) {
mdList.saveCredentials(username, password)
true
} else false
@ -207,7 +214,7 @@ class MangaDex(delegate: HttpSource, val context: Context) :
override suspend fun logout(): Boolean {
val result = try {
loginHelper.logout(MdUtil.getAuthHeaders(Headers.Builder().build(), preferences, mdList))
loginHelper.logout()
} catch (e: NoSessionException) {
true
} catch (e: Exception) {

View File

@ -0,0 +1,17 @@
package exh.md.dto
import kotlinx.serialization.Serializable
@Serializable
data class AtHomeDto(
val baseUrl: String,
)
@Serializable
data class AtHomeImageReportDto(
val url: String,
val success: Boolean,
val bytes: Int? = null,
val cached: Boolean? = null,
val duration: Long,
)

View File

@ -0,0 +1,39 @@
package exh.md.dto
import kotlinx.serialization.Serializable
/**
* Login Request object for Dex Api
*/
@Serializable
data class LoginRequestDto(val username: String, val password: String)
/**
* Response after login
*/
@Serializable
data class LoginResponseDto(val result: String, val token: LoginBodyTokenDto)
/**
* Tokens for the logins
*/
@Serializable
data class LoginBodyTokenDto(val session: String, val refresh: String)
/**
* Response after logout
*/
@Serializable
data class LogoutDto(val result: String)
/**
* Check if session token is valid
*/
@Serializable
data class CheckTokenDto(val isAuthenticated: Boolean)
/**
* Request to refresh token
*/
@Serializable
data class RefreshTokenDto(val token: String)

View File

@ -0,0 +1,62 @@
package exh.md.dto
import kotlinx.serialization.Serializable
@Serializable
data class ChapterListDto(
override val limit: Int,
override val offset: Int,
override val total: Int,
override val results: List<ChapterDto>,
) : ListCallDto<ChapterDto>
@Serializable
data class ChapterDto(
val result: String,
val data: ChapterDataDto,
val relationships: List<RelationshipDto>,
)
@Serializable
data class ChapterDataDto(
val id: String,
val type: String,
val attributes: ChapterAttributesDto,
)
@Serializable
data class ChapterAttributesDto(
val title: String?,
val volume: String?,
val chapter: String?,
val translatedLanguage: String,
val publishAt: String,
val data: List<String>,
val dataSaver: List<String>,
val hash: String,
)
@Serializable
data class GroupListDto(
val limit: Int,
val offset: Int,
val total: Int,
val results: List<GroupDto>,
)
@Serializable
data class GroupDto(
val result: String,
val data: GroupDataDto,
)
@Serializable
data class GroupDataDto(
val id: String,
val attributes: GroupAttributesDto,
)
@Serializable
data class GroupAttributesDto(
val name: String,
)

View File

@ -0,0 +1,8 @@
package exh.md.dto
interface ListCallDto<T> {
val limit: Int
val offset: Int
val total: Int
val results: List<T>
}

View File

@ -0,0 +1,119 @@
package exh.md.dto
import kotlinx.serialization.Serializable
@Serializable
data class MangaListDto(
override val limit: Int,
override val offset: Int,
override val total: Int,
override val results: List<MangaDto>,
) : ListCallDto<MangaDto>
@Serializable
data class MangaDto(
val result: String,
val data: MangaDataDto,
val relationships: List<RelationshipDto>,
)
@Serializable
data class MangaDataDto(val id: String, val type: String, val attributes: MangaAttributesDto)
@Serializable
data class MangaAttributesDto(
val title: Map<String, String>,
val altTitles: List<Map<String, String>>,
val description: Map<String, String>,
val links: Map<String, String>?,
val originalLanguage: String,
val lastVolume: String?,
val lastChapter: String?,
val contentRating: String?,
val publicationDemographic: String?,
val status: String?,
val year: Int?,
val tags: List<TagDto>,
)
@Serializable
data class TagDto(
val id: String,
val attributes: TagAttributesDto
)
@Serializable
data class TagAttributesDto(
val name: Map<String, String>
)
@Serializable
data class RelationshipDto(
val id: String,
val type: String,
val attributes: IncludesAttributesDto? = null,
)
@Serializable
data class IncludesAttributesDto(
val name: String? = null,
val fileName: String? = null,
)
@Serializable
data class AuthorListDto(
val results: List<AuthorDto>,
)
@Serializable
data class AuthorDto(
val result: String,
val data: AuthorDataDto,
)
@Serializable
data class AuthorDataDto(
val id: String,
val attributes: AuthorAttributesDto,
)
@Serializable
data class AuthorAttributesDto(
val name: String,
)
@Serializable
data class ReadingStatusDto(
val status: String?,
)
@Serializable
data class ReadingStatusMapDto(
val statuses: Map<String, String?>,
)
@Serializable
data class ReadChapterDto(
val data: List<String>,
)
@Serializable
data class CoverListDto(
val results: List<CoverDto>,
)
@Serializable
data class CoverDto(
val data: CoverDataDto,
val relationships: List<RelationshipDto>,
)
@Serializable
data class CoverDataDto(
val attributes: CoverAttributesDto,
)
@Serializable
data class CoverAttributesDto(
val fileName: String,
)

View File

@ -1,4 +1,4 @@
package exh.md.handlers.serializers
package exh.md.dto
import kotlinx.serialization.Serializable
import kotlinx.serialization.Serializer

View File

@ -0,0 +1,8 @@
package exh.md.dto
import kotlinx.serialization.Serializable
@Serializable
data class ResultDto(
val result: String,
)

View File

@ -1,20 +1,20 @@
package exh.md.handlers.serializers
package exh.md.dto
import kotlinx.serialization.Serializable
@Serializable
data class SimilarMangaResponse(
data class SimilarMangaDto(
val id: String,
val title: Map<String, String>,
val contentRating: String,
val matches: List<Matches>,
val updatedAt: String
val matches: List<SimilarMangaMatchListDto>,
val updatedAt: String,
)
@Serializable
data class Matches(
data class SimilarMangaMatchListDto(
val id: String,
val title: Map<String, String>,
val contentRating: String,
val score: Double
val score: Double,
)

View File

@ -1,37 +0,0 @@
package exh.md.handlers
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.source.model.Page
import exh.md.handlers.serializers.ChapterResponse
import exh.md.utils.MdUtil
import okhttp3.Response
class ApiChapterParser {
fun pageListParse(response: Response, host: String, dataSaver: Boolean): List<Page> {
val networkApiChapter = response.parseAs<ChapterResponse>(MdUtil.jsonParser)
val pages = mutableListOf<Page>()
val atHomeRequestUrl = response.request.url.toUrl().toString()
val hash = networkApiChapter.data.attributes.hash
val pageArray = if (dataSaver) {
networkApiChapter.data.attributes.dataSaver.map { "/data-saver/$hash/$it" }
} else {
networkApiChapter.data.attributes.data.map { "/data/$hash/$it" }
}
val now = System.currentTimeMillis()
pageArray.forEach { imgUrl ->
val mdAtHomeUrl = "$host,$atHomeRequestUrl,$now"
pages += Page(pages.size, mdAtHomeUrl, imgUrl)
}
return pages
}
fun externalParse(response: Response): String {
val chapterResponse = response.parseAs<ChapterResponse>()
val external = chapterResponse.data.attributes.data.first()
return external.substringAfterLast("/")
}
}

View File

@ -1,25 +1,20 @@
package exh.md.handlers
import com.elvishew.xlog.XLog
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.source.model.SManga
import exh.log.xLogE
import exh.md.handlers.serializers.AuthorResponseList
import exh.md.handlers.serializers.ChapterResponse
import exh.md.handlers.serializers.MangaResponse
import exh.md.dto.ChapterDto
import exh.md.dto.MangaDto
import exh.md.utils.MdConstants
import exh.md.utils.MdUtil
import exh.metadata.metadata.MangaDexSearchMetadata
import exh.metadata.metadata.base.RaisedTag
import exh.metadata.metadata.base.getFlatMetadataForManga
import exh.metadata.metadata.base.insertFlatMetadata
import exh.util.capitalize
import exh.util.dropEmpty
import exh.util.floor
import exh.util.nullIfEmpty
import okhttp3.OkHttpClient
import okhttp3.Response
import tachiyomi.source.model.ChapterInfo
import tachiyomi.source.model.MangaInfo
@ -27,7 +22,6 @@ import uy.kohesive.injekt.injectLazy
import java.util.Locale
class ApiMangaParser(
private val client: OkHttpClient,
private val lang: String
) {
val db: DatabaseHelper by injectLazy()
@ -42,11 +36,7 @@ class ApiMangaParser(
}?.call()
?: error("Could not find no-args constructor for meta class: ${metaClass.qualifiedName}!")
suspend fun parseToManga(manga: MangaInfo, input: Response, sourceId: Long): MangaInfo {
return parseToManga(manga, input.parseAs<MangaResponse>(MdUtil.jsonParser), sourceId)
}
suspend fun parseToManga(manga: MangaInfo, input: MangaResponse, sourceId: Long): MangaInfo {
fun parseToManga(manga: MangaInfo, input: MangaDto, sourceId: Long): MangaInfo {
val mangaId = db.getManga(manga.key, sourceId).executeAsBlocking()?.id
val metadata = if (mangaId != null) {
val flatMetadata = db.getFlatMetadataForManga(mangaId).executeAsBlocking()
@ -62,59 +52,34 @@ class ApiMangaParser(
return metadata.createMangaInfo(manga)
}
/**
* Parse the manga details json into metadata object
*/
suspend fun parseIntoMetadata(metadata: MangaDexSearchMetadata, input: Response) {
parseIntoMetadata(metadata, input.parseAs<MangaResponse>(MdUtil.jsonParser))
}
suspend fun parseIntoMetadata(metadata: MangaDexSearchMetadata, networkApiManga: MangaResponse) {
fun parseIntoMetadata(metadata: MangaDexSearchMetadata, mangaDto: MangaDto) {
with(metadata) {
try {
val networkManga = networkApiManga.data.attributes
mdUuid = networkApiManga.data.id
title = MdUtil.cleanString(networkManga.title[lang] ?: networkManga.title["en"]!!)
altTitles = networkManga.altTitles.mapNotNull { it[lang] }.nullIfEmpty()
val mangaAttributesDto = mangaDto.data.attributes
mdUuid = mangaDto.data.id
title = MdUtil.cleanString(mangaAttributesDto.title[lang] ?: mangaAttributesDto.title["en"]!!)
altTitles = mangaAttributesDto.altTitles.mapNotNull { it[lang] }.nullIfEmpty()
val coverId = networkApiManga.relationships.firstOrNull { it.type.equals("cover_art", true) }?.id
cover = MdUtil.getCoverUrl(networkApiManga.data.id, coverId, client)
mangaDto.relationships
.firstOrNull { relationshipDto -> relationshipDto.type == MdConstants.Types.coverArt }
?.attributes
?.fileName
?.let { coverFileName ->
cover = MdUtil.cdnCoverUrl(mangaDto.data.id, coverFileName)
}
description = MdUtil.cleanDescription(networkManga.description[lang] ?: networkManga.description["en"]!!)
description = MdUtil.cleanDescription(mangaAttributesDto.description[lang] ?: mangaAttributesDto.description["en"]!!)
val authorIds = networkApiManga.relationships
.filter { it.type.equals("author", true) }
.map { it.id }
.toSet()
val artistIds = networkApiManga.relationships
.filter { it.type.equals("artist", true) }
.map { it.id }
.toSet()
authors = mangaDto.relationships.filter { relationshipDto ->
relationshipDto.type.equals(MdConstants.Types.author, true)
}.mapNotNull { it.attributes!!.name }.distinct()
// get author/artist map ignore if they error
val authorMap = runCatching {
(authorIds + artistIds).chunked(10)
.flatMap { idList ->
val ids = idList.joinToString("&ids[]=", "?ids[]=")
val response = client.newCall(GET("${MdUtil.authorUrl}$ids")).await()
if (response.code != 204) {
response
.parseAs<AuthorResponseList>()
.results.map {
it.data.id to MdUtil.cleanString(it.data.attributes.name)
}
} else {
emptyList()
}
}
.toMap()
}.getOrNull() ?: emptyMap()
artists = mangaDto.relationships.filter { relationshipDto ->
relationshipDto.type.equals(MdConstants.Types.artist, true)
}.mapNotNull { it.attributes!!.name }.distinct()
authors = authorIds.mapNotNull { authorMap[it] }.dropEmpty()
artists = artistIds.mapNotNull { authorMap[it] }.dropEmpty()
langFlag = networkManga.originalLanguage
val lastChapter = networkManga.lastChapter?.toFloatOrNull()
langFlag = mangaAttributesDto.originalLanguage
val lastChapter = mangaAttributesDto.lastChapter?.toFloatOrNull()
lastChapterNumber = lastChapter?.floor()
/*networkManga.rating?.let {
@ -122,7 +87,7 @@ class ApiMangaParser(
manga.users = it.users
}*/
networkManga.links?.let { links ->
mangaAttributesDto.links?.let { links ->
links["al"]?.let { anilistId = it }
links["kt"]?.let { kitsuId = it }
links["mal"]?.let { myAnimeListId = it }
@ -132,7 +97,7 @@ class ApiMangaParser(
// val filteredChapters = filterChapterForChecking(networkApiManga)
val tempStatus = parseStatus(networkManga.status ?: "")
val tempStatus = parseStatus(mangaAttributesDto.status ?: "")
/*val publishedOrCancelled =
tempStatus == SManga.PUBLICATION_COMPLETE || tempStatus == SManga.CANCELLED
if (publishedOrCancelled && isMangaCompleted(networkApiManga, filteredChapters)) {
@ -144,17 +109,18 @@ class ApiMangaParser(
// things that will go with the genre tags but aren't actually genre
val nonGenres = listOfNotNull(
networkManga.publicationDemographic
mangaAttributesDto.publicationDemographic
?.let { RaisedTag("Demographic", it.capitalize(Locale.US), MangaDexSearchMetadata.TAG_TYPE_DEFAULT) },
networkManga.contentRating
mangaAttributesDto.contentRating
?.takeUnless { it == "safe" }
?.let { RaisedTag("Content Rating", it.capitalize(Locale.US), MangaDexSearchMetadata.TAG_TYPE_DEFAULT) },
)
val genres = nonGenres + networkManga.tags
.mapNotNull { dexTag ->
dexTag.attributes.name[lang] ?: dexTag.attributes.name["en"]
}.map {
val genres = nonGenres + mangaAttributesDto.tags
.mapNotNull {
it.attributes.name[lang] ?: it.attributes.name["en"]
}
.map {
RaisedTag("Tags", it, MangaDexSearchMetadata.TAG_TYPE_DEFAULT)
}
@ -223,14 +189,7 @@ class ApiMangaParser(
else -> SManga.UNKNOWN
}
/**
* Parse for the random manga id from the [MdUtil.randMangaPage] response.
*/
fun randomMangaIdParse(response: Response): String {
return response.parseAs<MangaResponse>(MdUtil.jsonParser).data.id
}
fun chapterListParse(chapterListResponse: List<ChapterResponse>, groupMap: Map<String, String>): List<ChapterInfo> {
fun chapterListParse(chapterListResponse: List<ChapterDto>, groupMap: Map<String, String>): List<ChapterInfo> {
val now = System.currentTimeMillis()
return chapterListResponse.asSequence()
@ -242,19 +201,14 @@ class ApiMangaParser(
}
fun chapterParseForMangaId(response: Response): String {
try {
return response.parseAs<ChapterResponse>(MdUtil.jsonParser)
.relationships.firstOrNull { it.type.equals("manga", true) }?.id ?: throw Exception("Not found")
} catch (e: Exception) {
XLog.e(e)
throw e
}
return response.parseAs<ChapterDto>(MdUtil.jsonParser)
.relationships.firstOrNull { it.type.equals("manga", true) }?.id ?: throw Exception("Not found")
}
fun StringBuilder.appends(string: String) = append("$string ")
fun StringBuilder.appends(string: String): StringBuilder = append("$string ")
private fun mapChapter(
networkChapter: ChapterResponse,
networkChapter: ChapterDto,
groups: Map<String, String>,
): ChapterInfo {
val attributes = networkChapter.data.attributes

View File

@ -1,13 +1,10 @@
package exh.md.handlers
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import okhttp3.HttpUrl
import java.util.Locale
class FilterHandler(private val preferencesHelper: PreferencesHelper) {
class FilterHandler {
internal fun getMDFilterList(): FilterList {
val filters = mutableListOf(
OriginalLanguageList(getOriginalLanguage()),
@ -85,7 +82,7 @@ class FilterHandler(private val preferencesHelper: PreferencesHelper) {
Tag("4d32cc48-9f00-4cca-9b5a-a839f0764984", "Comedy"),
Tag("ea2bc92d-1c26-4930-9b7c-d5c0dc1b6869", "Cooking"),
Tag("5ca48985-9a9d-4bd8-be29-80dc0303db72", "Crime"),
Tag("489dd859-9b61-4c37-af75-5b18e88daafc", "Crossdressing"),
Tag("9ab53f92-3eed-4e9b-903a-917c86035ee3", "Crossdressing"),
Tag("da2d50ca-3018-4cc0-ac7a-6b7d472a29ea", "Delinquents"),
Tag("39730448-9a5f-48a2-85b0-a70db87b1233", "Demons"),
Tag("b13b2a48-c720-44a9-9c77-39c9979373fb", "Doujinshi"),
@ -160,108 +157,99 @@ class FilterHandler(private val preferencesHelper: PreferencesHelper) {
Filter.Select<String>("Excluded tags mode", arrayOf("And", "Or"), 1)
val sortableList = listOf(
Pair("Default (Asc/Desc doesn't matter)", ""),
Pair("Number of follows", ""),
Pair("Created at", "createdAt"),
Pair("Updated at", "updatedAt"),
)
class SortFilter(sortables: Array<String>) : Filter.Sort("Sort", sortables, Selection(0, false))
fun addFiltersToUrl(url: HttpUrl.Builder, filters: FilterList): String {
url.apply {
// add filters
filters.forEach { filter ->
when (filter) {
is OriginalLanguageList -> {
filter.state.forEach { lang ->
if (lang.state) {
addQueryParameter(
"originalLanguage[]",
lang.isoCode
)
}
fun getQueryMap(filters: FilterList): Map<String, Any> {
val queryMap = mutableMapOf<String, Any>()
val originalLanguageList = mutableListOf<String>() // originalLanguage[]
val contentRatingList = mutableListOf<String>() // contentRating[]
val demographicList = mutableListOf<String>() // publicationDemographic[]
val statusList = mutableListOf<String>() // status[]
val includeTagList = mutableListOf<String>() // includedTags[]
val excludeTagList = mutableListOf<String>() // excludedTags[]
// add filters
filters.forEach { filter ->
when (filter) {
is OriginalLanguageList -> {
filter.state.filter { lang -> lang.state }
.forEach { lang ->
if (lang.isoCode == "zh") {
addQueryParameter(
"originalLanguage[]",
"zh-hk"
)
originalLanguageList.add("zh-hk")
}
originalLanguageList.add(lang.isoCode)
}
}
is ContentRatingList -> {
filter.state.forEach { rating ->
if (rating.state) {
addQueryParameter(
"contentRating[]",
rating.name.lowercase(Locale.US)
)
}
}
is ContentRatingList -> {
filter.state.filter { rating -> rating.state }
.forEach { rating ->
contentRatingList.add(rating.name.lowercase(Locale.US))
}
}
is DemographicList -> {
filter.state.forEach { demographic ->
if (demographic.state) {
addQueryParameter(
"publicationDemographic[]",
demographic.name.lowercase(
Locale.US
)
)
}
}
is DemographicList -> {
filter.state.filter { demographic -> demographic.state }
.forEach { demographic ->
demographicList.add(demographic.name.lowercase(Locale.US))
}
}
is StatusList -> {
filter.state.forEach { status ->
if (status.state) {
addQueryParameter(
"status[]",
status.name.lowercase(
Locale.US
)
)
}
}
is StatusList -> {
filter.state.filter { status -> status.state }
.forEach { status ->
statusList.add(status.name.lowercase(Locale.US))
}
}
is SortFilter -> {
if (filter.state != null) {
if (filter.state!!.index != 0) {
val query = sortableList[filter.state!!.index].second
val value = when (filter.state!!.ascending) {
true -> "asc"
false -> "desc"
}
addQueryParameter("order[$query]", value)
}
}
is SortFilter -> {
if (filter.state != null && filter.state!!.index != 0) {
val query = sortableList[filter.state!!.index].second
val value = when (filter.state!!.ascending) {
true -> "asc"
false -> "desc"
}
}
is TagList -> {
filter.state.forEach { tag ->
if (tag.isIncluded()) {
addQueryParameter("includedTags[]", tag.id)
} else if (tag.isExcluded()) {
addQueryParameter("excludedTags[]", tag.id)
}
}
}
is TagInclusionMode -> {
addQueryParameter(
"includedTagsMode",
filter.values[filter.state].uppercase(Locale.US)
)
}
is TagExclusionMode -> {
addQueryParameter(
"excludedTagsMode",
filter.values[filter.state].uppercase(Locale.US)
)
queryMap["order[$query]"] = value
}
}
}
if (false) { // preferencesHelper.showR18Filter().not()) {
addQueryParameter("contentRating[]", "safe")
is TagList -> {
filter.state.forEach { tag ->
if (tag.isIncluded()) {
includeTagList.add(tag.id)
} else if (tag.isExcluded()) {
excludeTagList.add(tag.id)
}
}
}
is TagInclusionMode -> {
queryMap["includedTagsMode"] = filter.values[filter.state].uppercase(Locale.US)
}
is TagExclusionMode -> {
queryMap["excludedTagsMode"] = filter.values[filter.state].uppercase(Locale.US)
}
}
}
if (originalLanguageList.isNotEmpty()) {
queryMap["originalLanguage[]"] = originalLanguageList
}
if (contentRatingList.isNotEmpty()) {
queryMap["contentRating[]"] = contentRatingList
}
if (demographicList.isNotEmpty()) {
queryMap["publicationDemographic[]"] = demographicList
}
if (statusList.isNotEmpty()) {
queryMap["status[]"] = statusList
}
if (includeTagList.isNotEmpty()) {
queryMap["includedTags[]"] = includeTagList
}
if (excludeTagList.isNotEmpty()) {
queryMap["excludedTags[]"] = excludeTagList
}
return url.toString()
return queryMap
}
}

View File

@ -1,45 +1,24 @@
package exh.md.handlers
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.mdlist.MdList
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.source.model.MetadataMangasPage
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.toSManga
import eu.kanade.tachiyomi.util.lang.withIOContext
import exh.md.handlers.serializers.MangaListResponse
import exh.md.handlers.serializers.MangaResponse
import exh.md.handlers.serializers.MangaStatusListResponse
import exh.md.handlers.serializers.MangaStatusResponse
import exh.md.handlers.serializers.ResultResponse
import exh.md.handlers.serializers.UpdateReadingStatus
import exh.md.dto.MangaDto
import exh.md.dto.ReadingStatusDto
import exh.md.service.MangaDexAuthService
import exh.md.utils.FollowStatus
import exh.md.utils.MdUtil
import exh.md.utils.mdListCall
import exh.metadata.metadata.MangaDexSearchMetadata
import exh.util.under
import kotlinx.serialization.encodeToString
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import java.util.Locale
import kotlinx.coroutines.async
class FollowsHandler(
private val client: OkHttpClient,
private val headers: Headers,
private val preferences: PreferencesHelper,
private val lang: String,
private val mdList: MdList
private val service: MangaDexAuthService
) {
/**
@ -47,21 +26,15 @@ class FollowsHandler(
*/
suspend fun fetchFollows(page: Int): MetadataMangasPage {
return withIOContext {
val response = client.newCall(followsListRequest(MdUtil.mangaLimit * page - 1)).await()
if (response.code == 204) {
val follows = service.userFollowList(MdUtil.mangaLimit * page)
if (follows.results.isEmpty()) {
return@withIOContext MetadataMangasPage(emptyList(), false, emptyList())
}
val mangaListResponse = response.parseAs<MangaListResponse>(MdUtil.jsonParser)
if (mangaListResponse.results.isEmpty()) {
return@withIOContext MetadataMangasPage(emptyList(), false, emptyList())
}
val hasMoreResults = mangaListResponse.limit + mangaListResponse.offset under mangaListResponse.total
val statusListResponse = client.newCall(mangaStatusListRequest()).await()
.parseAs<MangaStatusListResponse>()
val results = followsParseMangaPage(mangaListResponse.results, statusListResponse.statuses)
val hasMoreResults = follows.limit + follows.offset under follows.total
val statusListResponse = service.readingStatusAllManga()
val results = followsParseMangaPage(follows.results, statusListResponse.statuses)
MetadataMangasPage(results.map { it.first }, hasMoreResults, results.map { it.second })
}
@ -71,53 +44,23 @@ class FollowsHandler(
* Parse follows api to manga page
* used when multiple follows
*/
private suspend fun followsParseMangaPage(response: List<MangaResponse>, statuses: Map<String, String?>): List<Pair<SManga, MangaDexSearchMetadata>> {
private fun followsParseMangaPage(
response: List<MangaDto>,
statuses: Map<String, String?>
): List<Pair<SManga, MangaDexSearchMetadata>> {
val comparator = compareBy<Pair<SManga, MangaDexSearchMetadata>> { it.second.followStatus }
.thenBy { it.first.title }
val coverMap = MdUtil.getCoversFromMangaList(response, client)
return response.map {
MdUtil.createMangaEntry(
it,
lang,
coverMap[it.data.id]
lang
).toSManga() to MangaDexSearchMetadata().apply {
followStatus = FollowStatus.fromDex(statuses[it.data.id]).int
}
}.sortedWith(comparator)
}
/**
* fetch follow status used when fetching status for 1 manga
*/
private fun followStatusParse(response: Response, sResponse: Response): Track {
val mangaResponse = response.parseAs<MangaResponse>(MdUtil.jsonParser)
val statusResponse = sResponse.parseAs<MangaStatusResponse>()
val track = Track.create(TrackManager.MDLIST)
track.status = FollowStatus.fromDex(statusResponse.status).int
track.tracking_url = MdUtil.baseUrl + "/manga/" + mangaResponse.data.id
track.title = mangaResponse.data.attributes.title[lang] ?: mangaResponse.data.attributes.title["en"]!!
/* if (follow.chapter.isNotBlank()) {
track.last_chapter_read = follow.chapter.toFloat().floor()
}*/
return track
}
/**
* build Request for follows page
*/
private fun followsListRequest(offset: Int): Request {
val tempUrl = MdUtil.userFollows.toHttpUrl().newBuilder()
tempUrl.apply {
addQueryParameter("limit", MdUtil.mangaLimit.toString())
addQueryParameter("offset", offset.toString())
}
return GET(tempUrl.build().toString(), MdUtil.getAuthHeaders(headers, preferences, mdList), CacheControl.FORCE_NETWORK)
}
/**
* Change the status of a manga
*/
@ -125,21 +68,17 @@ class FollowsHandler(
return withIOContext {
val status = when (followStatus == FollowStatus.UNFOLLOWED) {
true -> null
false -> followStatus.name.lowercase(Locale.US)
false -> followStatus.toDex()
}
val readingStatusDto = ReadingStatusDto(status)
if (followStatus == FollowStatus.UNFOLLOWED) {
service.unfollowManga(mangaId)
} else {
service.followManga(mangaId)
}
val jsonString = MdUtil.jsonParser.encodeToString(UpdateReadingStatus(status))
val postResult = client.newCall(
POST(
MdUtil.updateReadingStatusUrl(mangaId),
MdUtil.getAuthHeaders(headers, preferences, mdList),
jsonString.toRequestBody("application/json".toMediaType())
)
).await()
val body = postResult.parseAs<ResultResponse>(MdUtil.jsonParser)
body.result == "ok"
service.updateReadingStatusForManga(mangaId, readingStatusDto).result == "ok"
}
}
@ -203,43 +142,26 @@ class FollowsHandler(
*/
suspend fun fetchAllFollows(): List<Pair<SManga, MangaDexSearchMetadata>> {
return withIOContext {
val results = client.mdListCall<MangaResponse> {
followsListRequest(it)
val results = async {
mdListCall {
service.userFollowList(it)
}
}
val statuses = client.newCall(mangaStatusListRequest()).await()
.parseAs<MangaStatusListResponse>().statuses
val readingStatusResponse = async { service.readingStatusAllManga().statuses }
followsParseMangaPage(results, statuses)
followsParseMangaPage(results.await(), readingStatusResponse.await())
}
}
suspend fun fetchTrackingInfo(url: String): Track {
return withIOContext {
val mangaId = MdUtil.getMangaId(url)
val request = GET(
MdUtil.mangaUrl + "/" + mangaId,
MdUtil.getAuthHeaders(headers, preferences, mdList),
CacheControl.FORCE_NETWORK
)
val statusRequest = GET(
MdUtil.mangaUrl + "/" + mangaId + "/status",
MdUtil.getAuthHeaders(headers, preferences, mdList),
CacheControl.FORCE_NETWORK
)
val response = client.newCall(request).await()
val statusResponse = client.newCall(statusRequest).await()
followStatusParse(response, statusResponse)
val followStatus = FollowStatus.fromDex(service.readingStatusForManga(mangaId).status)
Track.create(TrackManager.MDLIST).apply {
status = followStatus.int
tracking_url = "${MdUtil.baseUrl}/title/$mangaId"
}
}
}
private fun mangaStatusListRequest(status: FollowStatus? = null): Request {
val mangaStatusUrl = MdUtil.mangaStatus.toHttpUrl().newBuilder()
if (status != null) {
mangaStatusUrl.addQueryParameter("status", status.name.lowercase(Locale.US))
}
return GET(mangaStatusUrl.build().toString(), MdUtil.getAuthHeaders(headers, preferences, mdList), CacheControl.FORCE_NETWORK)
}
}

View File

@ -1,9 +1,6 @@
package exh.md.handlers
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.toMangaInfo
@ -11,55 +8,25 @@ import eu.kanade.tachiyomi.source.model.toSChapter
import eu.kanade.tachiyomi.source.model.toSManga
import eu.kanade.tachiyomi.util.lang.runAsObservable
import eu.kanade.tachiyomi.util.lang.withIOContext
import exh.md.handlers.serializers.ChapterResponse
import exh.md.handlers.serializers.GroupListResponse
import exh.md.dto.ChapterDto
import exh.md.service.MangaDexService
import exh.md.utils.MdConstants
import exh.md.utils.MdUtil
import exh.md.utils.mdListCall
import exh.metadata.metadata.MangaDexSearchMetadata
import kotlinx.coroutines.async
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import rx.Observable
import tachiyomi.source.model.ChapterInfo
import tachiyomi.source.model.MangaInfo
import uy.kohesive.injekt.api.get
class MangaHandler(
private val client: OkHttpClient,
private val headers: Headers,
private val lang: String,
private val service: MangaDexService,
private val apiMangaParser: ApiMangaParser,
private val followsHandler: FollowsHandler
) {
suspend fun fetchMangaAndChapterDetails(manga: MangaInfo, sourceId: Long, forceLatestCovers: Boolean): Pair<MangaInfo, List<ChapterInfo>> {
return withIOContext {
val response = client.newCall(mangaRequest(manga)).await()
apiMangaParser.parseToManga(manga, response, sourceId) to getChapterList(manga)
}
}
/*suspend fun getCovers(manga: MangaInfo, forceLatestCovers: Boolean): List<String> {
*//* if (forceLatestCovers) {
val covers = client.newCall(coverRequest(manga)).await().parseAs<ApiCovers>(MdUtil.jsonParser)
return covers.data.map { it.url }
} else {*//*
return emptyList()
// }
}*/
suspend fun getMangaIdFromChapterId(urlChapterId: String): String {
return withIOContext {
val request = GET(MdUtil.chapterUrl + urlChapterId)
val response = client.newCall(request).await()
apiMangaParser.chapterParseForMangaId(response)
}
}
suspend fun getMangaDetails(manga: MangaInfo, sourceId: Long, forceLatestCovers: Boolean): MangaInfo {
val response = withIOContext { client.newCall(mangaRequest(manga)).await() }
val response = withIOContext { service.viewManga(MdUtil.getMangaId(manga.key)) }
return apiMangaParser.parseToManga(manga, response, sourceId)
}
@ -75,8 +42,8 @@ class MangaHandler(
suspend fun getChapterList(manga: MangaInfo): List<ChapterInfo> {
return withIOContext {
val results = client.mdListCall<ChapterResponse> {
mangaFeedRequest(manga, it, lang)
val results = mdListCall {
service.viewChapters(MdUtil.getMangaId(manga.key), lang, it)
}
val groupMap = getGroupMap(results)
@ -85,31 +52,17 @@ class MangaHandler(
}
}
private suspend fun getGroupMap(results: List<ChapterResponse>): Map<String, String> {
val groupIds = results.asSequence()
.flatMap { chapter -> chapter.relationships }
.filter { it.type == "scanlation_group" }
.map { it.id }
.toSet()
return runCatching {
groupIds.chunked(100).flatMapIndexed { index, ids ->
val response = client.newCall(groupIdRequest(ids, 100 * index)).await()
if (response.code != 204) {
response
.parseAs<GroupListResponse>(MdUtil.jsonParser)
.results.map { group -> group.data.id to group.data.attributes.name }
} else {
emptyList()
}
}.toMap()
}.getOrNull().orEmpty()
private fun getGroupMap(results: List<ChapterDto>): Map<String, String> {
return results.map { chapter -> chapter.relationships }
.flatten()
.filter { it.type == MdConstants.Types.scanlator }
.map { it.id to it.attributes!!.name!! }
.toMap()
}
suspend fun fetchRandomMangaId(): String {
return withIOContext {
val response = client.newCall(randomMangaRequest()).await()
apiMangaParser.randomMangaIdParse(response)
service.randomManga().data.id
}
}
@ -129,28 +82,4 @@ class MangaHandler(
remoteTrack.await() to null
}
}
private fun randomMangaRequest(): Request {
return GET(MdUtil.randomMangaUrl, cache = CacheControl.FORCE_NETWORK)
}
private fun mangaRequest(manga: MangaInfo): Request {
return GET(MdUtil.mangaUrl + "/" + MdUtil.getMangaId(manga.key), headers, CacheControl.FORCE_NETWORK)
}
private fun mangaFeedRequest(manga: MangaInfo, offset: Int, lang: String): Request {
return GET(MdUtil.mangaFeedUrl(MdUtil.getMangaId(manga.key), offset, lang), headers, CacheControl.FORCE_NETWORK)
}
private fun groupIdRequest(id: List<String>, offset: Int): Request {
val urlSuffix = id.joinToString("&ids[]=", "?limit=100&offset=$offset&ids[]=")
return GET(MdUtil.groupUrl + urlSuffix, headers)
}
/* private fun coverRequest(manga: SManga): Request {
return GET(MdUtil.apiUrl + MdUtil.apiManga + MdUtil.getMangaId(manga.url) + MdUtil.apiCovers, headers, CacheControl.FORCE_NETWORK)
}*/
companion object {
}
}

View File

@ -2,7 +2,7 @@ package exh.md.handlers
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.model.Page
import exh.md.handlers.serializers.MangaPlusSerializer
import exh.md.dto.MangaPlusSerializer
import kotlinx.serialization.protobuf.ProtoBuf
import okhttp3.Headers
import okhttp3.Interceptor

View File

@ -2,57 +2,65 @@ package exh.md.handlers
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.mdlist.MdList
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.util.lang.withIOContext
import exh.md.dto.AtHomeDto
import exh.md.dto.ChapterDto
import exh.md.service.MangaDexService
import exh.md.utils.MdUtil
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import rx.Observable
class PageHandler(
private val client: OkHttpClient,
private val headers: Headers,
private val apiChapterParser: ApiChapterParser,
private val service: MangaDexService,
private val mangaPlusHandler: MangaPlusHandler,
private val preferences: PreferencesHelper,
private val mdList: MdList,
) {
fun fetchPageList(chapter: SChapter, isLogged: Boolean, usePort443Only: Boolean, dataSaver: Boolean): Observable<List<Page>> {
if (chapter.scanlator.equals("MangaPlus")) {
return client.newCall(pageListRequest(chapter))
.asObservableSuccess()
.map { response ->
val chapterId = apiChapterParser.externalParse(response)
mangaPlusHandler.fetchPageList(chapterId)
suspend fun fetchPageList(chapter: SChapter, isLogged: Boolean, usePort443Only: Boolean, dataSaver: Boolean): List<Page> {
return withIOContext {
val chapterResponse = service.viewChapter(MdUtil.getChapterId(chapter.url))
if (chapter.scanlator.equals("mangaplus", true)) {
mangaPlusHandler.fetchPageList(
chapterResponse.data.attributes.data
.first()
.substringAfterLast("/")
)
} else {
val headers = if (isLogged) {
MdUtil.getAuthHeaders(headers, preferences, mdList)
} else {
headers
}
}
val atHomeRequestUrl = if (usePort443Only) {
"${MdUtil.atHomeUrl}/${MdUtil.getChapterId(chapter.url)}?forcePort443=true"
} else {
"${MdUtil.atHomeUrl}/${MdUtil.getChapterId(chapter.url)}"
}
val (atHomeRequestUrl, atHomeResponse) = service.getAtHomeServer(headers, MdUtil.getChapterId(chapter.url), usePort443Only)
val headers = if (isLogged) {
MdUtil.getAuthHeaders(headers, preferences, mdList)
} else {
headers
}
return client.newCall(pageListRequest(chapter))
.asObservableSuccess()
.map { response ->
val host = MdUtil.atHomeUrlHostUrl(atHomeRequestUrl, client, headers, CacheControl.FORCE_NETWORK)
apiChapterParser.pageListParse(response, host, dataSaver)
pageListParse(chapterResponse, atHomeRequestUrl, atHomeResponse, dataSaver)
}
}
}
private fun pageListRequest(chapter: SChapter): Request {
return GET(MdUtil.chapterUrl + MdUtil.getChapterId(chapter.url), headers, CacheControl.FORCE_NETWORK)
fun pageListParse(
chapterDto: ChapterDto,
atHomeRequestUrl: String,
atHomeDto: AtHomeDto,
dataSaver: Boolean,
): List<Page> {
val hash = chapterDto.data.attributes.hash
val pageArray = if (dataSaver) {
chapterDto.data.attributes.dataSaver.map { "/data-saver/$hash/$it" }
} else {
chapterDto.data.attributes.data.map { "/data/$hash/$it" }
}
val now = System.currentTimeMillis()
val pages = pageArray.mapIndexed { pos, imgUrl ->
Page(pos + 1, "${atHomeDto.baseUrl},$atHomeRequestUrl,$now", imgUrl)
}
return pages
}
}

View File

@ -1,60 +0,0 @@
package exh.md.handlers
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.toSManga
import eu.kanade.tachiyomi.util.lang.runAsObservable
import exh.md.handlers.serializers.MangaListResponse
import exh.md.utils.MdUtil
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
/**
* Returns the latest manga from the updates url since it actually respects the users settings
*/
class PopularHandler(
private val client: OkHttpClient,
private val headers: Headers,
private val lang: String
) {
fun fetchPopularManga(page: Int): Observable<MangasPage> {
return client.newCall(popularMangaRequest(page))
.asObservableSuccess()
.flatMap { response ->
runAsObservable({
popularMangaParse(response)
})
}
}
private fun popularMangaRequest(page: Int): Request {
val tempUrl = MdUtil.mangaUrl.toHttpUrl().newBuilder()
tempUrl.apply {
addQueryParameter("limit", MdUtil.mangaLimit.toString())
addQueryParameter("offset", (MdUtil.getMangaListOffset(page)))
}
return GET(tempUrl.build().toString(), headers, CacheControl.FORCE_NETWORK)
}
private suspend fun popularMangaParse(response: Response): MangasPage {
val mlResponse = response.parseAs<MangaListResponse>(MdUtil.jsonParser)
val hasMoreResults = mlResponse.limit + mlResponse.offset < mlResponse.total
val coverMap = MdUtil.getCoversFromMangaList(mlResponse.results, client)
val mangaList = mlResponse.results.map {
MdUtil.createMangaEntry(it, lang, coverMap[it.data.id]).toSManga()
}
return MangasPage(mangaList, hasMoreResults)
}
}

View File

@ -1,93 +0,0 @@
package exh.md.handlers
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.toSManga
import eu.kanade.tachiyomi.util.lang.runAsObservable
import exh.md.handlers.serializers.MangaListResponse
import exh.md.handlers.serializers.MangaResponse
import exh.md.utils.MdUtil
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
class SearchHandler(
private val client: OkHttpClient,
private val headers: Headers,
private val lang: String,
private val filterHandler: FilterHandler,
private val apiMangaParser: ApiMangaParser
) {
fun fetchSearchManga(page: Int, query: String, filters: FilterList, sourceId: Long): Observable<MangasPage> {
return if (query.startsWith(PREFIX_ID_SEARCH)) {
val realQuery = query.removePrefix(PREFIX_ID_SEARCH)
client.newCall(searchMangaByIdRequest(realQuery))
.asObservableSuccess()
.flatMap { response ->
runAsObservable({
val mangaResponse = response.parseAs<MangaResponse>(MdUtil.jsonParser)
val details = apiMangaParser
.parseToManga(MdUtil.createMangaEntry(mangaResponse, lang, null), response, sourceId).toSManga()
MangasPage(listOf(details), false)
})
}
} else {
client.newCall(searchMangaRequest(page, query, filters))
.asObservableSuccess()
.flatMap { response ->
runAsObservable({
searchMangaParse(response)
})
}
}
}
private suspend fun searchMangaParse(response: Response): MangasPage {
val mlResponse = response.parseAs<MangaListResponse>(MdUtil.jsonParser)
val coverMap = MdUtil.getCoversFromMangaList(mlResponse.results, client)
val hasMoreResults = mlResponse.limit + mlResponse.offset < mlResponse.total
val mangaList = mlResponse.results.map {
MdUtil.createMangaEntry(it, lang, coverMap[it.data.id]).toSManga()
}
return MangasPage(mangaList, hasMoreResults)
}
private fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val tempUrl = MdUtil.mangaUrl.toHttpUrl().newBuilder()
tempUrl.apply {
addQueryParameter("limit", MdUtil.mangaLimit.toString())
addQueryParameter("offset", (MdUtil.getMangaListOffset(page)))
val actualQuery = query.replace(WHITESPACE_REGEX, " ")
if (actualQuery.isNotBlank()) {
addQueryParameter("title", actualQuery)
}
}
val finalUrl = filterHandler.addFiltersToUrl(tempUrl, filters)
return GET(finalUrl, headers, CacheControl.FORCE_NETWORK)
}
private fun searchMangaByIdRequest(id: String): Request {
return GET(MdUtil.mangaUrl + "/" + id, headers, CacheControl.FORCE_NETWORK)
}
private fun searchMangaByGroupRequest(group: String): Request {
return GET(MdUtil.groupSearchUrl + group, headers, CacheControl.FORCE_NETWORK)
}
companion object {
const val PREFIX_ID_SEARCH = "id:"
const val PREFIX_GROUP_SEARCH = "group:"
val WHITESPACE_REGEX = "\\s".toRegex()
}
}

View File

@ -1,65 +1,35 @@
package exh.md.handlers
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.SManga
import exh.md.handlers.serializers.CoverListResponse
import exh.md.handlers.serializers.SimilarMangaResponse
import eu.kanade.tachiyomi.source.model.toSManga
import exh.md.dto.SimilarMangaDto
import exh.md.service.MangaDexService
import exh.md.service.SimilarService
import exh.md.utils.MdUtil
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import tachiyomi.source.model.MangaInfo
class SimilarHandler(
private val client: OkHttpClient,
private val lang: String
private val lang: String,
private val service: MangaDexService,
private val similarService: SimilarService
) {
suspend fun getSimilar(manga: MangaInfo): MangasPage {
val response = client.newCall(similarMangaRequest(manga)).await()
.parseAs<SimilarMangaResponse>()
val ids = response.matches.map { it.id }
val coverUrl = MdUtil.coverUrl.toHttpUrl().newBuilder().apply {
ids.forEach { mangaId ->
addQueryParameter("manga[]", mangaId)
}
addQueryParameter("limit", ids.size.toString())
}.build().toString()
val coverListResponse = client.newCall(GET(coverUrl)).await()
.parseAs<CoverListResponse>()
val unique = coverListResponse.results.distinctBy { it.relationships[0].id }
val coverMap = unique.map { coverResponse ->
val fileName = coverResponse.data.attributes.fileName
val mangaId = coverResponse.relationships.first { it.type.equals("manga", true) }.id
val thumbnailUrl = "${MdUtil.cdnUrl}/covers/$mangaId/$fileName"
mangaId to thumbnailUrl
}.toMap()
return similarMangaParse(response, coverMap)
val similarDto = similarService.getSimilarManga(MdUtil.getMangaId(manga.key))
return similarDtoToMangaListPage(similarDto)
}
private fun similarMangaRequest(manga: MangaInfo): Request {
val tempUrl = MdUtil.similarBaseApi + MdUtil.getMangaId(manga.key) + ".json"
return GET(tempUrl, Headers.Builder().build(), CacheControl.FORCE_NETWORK)
}
private fun similarMangaParse(response: SimilarMangaResponse, coverMap: Map<String, String>): MangasPage {
val mangaList = response.matches.map {
SManga.create().apply {
url = MdUtil.buildMangaUrl(it.id)
title = MdUtil.cleanString(it.title[lang] ?: it.title["en"]!!)
thumbnail_url = coverMap[it.id]
}
private suspend fun similarDtoToMangaListPage(
similarMangaDto: SimilarMangaDto,
): MangasPage {
val ids = similarMangaDto.matches.map {
it.id
}
val mangaList = service.viewMangas(ids).results.map {
MdUtil.createMangaEntry(it, lang).toSManga()
}
return MangasPage(mangaList, false)
}
}

View File

@ -1,39 +0,0 @@
package exh.md.handlers.serializers
import kotlinx.serialization.Serializable
/**
* Login Request object for Dex Api
*/
@Serializable
data class LoginRequest(val username: String, val password: String)
/**
* Response after login
*/
@Serializable
data class LoginResponse(val result: String, val token: LoginBodyToken)
/**
* Tokens for the logins
*/
@Serializable
data class LoginBodyToken(val session: String, val refresh: String)
/**
* Response after logout
*/
@Serializable
data class ResultResponse(val result: String)
/**
* Check if session token is valid
*/
@Serializable
data class CheckTokenResponse(val isAuthenticated: Boolean)
/**
* Request to refresh token
*/
@Serializable
data class RefreshTokenRequest(val token: String)

View File

@ -1,40 +0,0 @@
package exh.md.handlers.serializers
import kotlinx.serialization.Serializable
@Serializable
data class CacheApiMangaSerializer(
val id: Long,
val title: String,
val url: String,
val description: String,
val is_r18: Boolean,
val rating: Float,
val demographic: List<String>,
val content: List<String>,
val format: List<String>,
val genre: List<String>,
val theme: List<String>,
val languages: List<String>,
val related: List<CacheRelatedSerializer>,
val external: MutableMap<String, String>,
val last_updated: String,
val matches: List<CacheSimilarMatchesSerializer>,
)
@Serializable
data class CacheRelatedSerializer(
val id: Long,
val title: String,
val type: String,
val r18: Boolean,
)
@Serializable
data class CacheSimilarMatchesSerializer(
val id: Long,
val title: String,
val score: Float,
val r18: Boolean,
val languages: List<String>,
)

View File

@ -1,67 +0,0 @@
package exh.md.handlers.serializers
import kotlinx.serialization.Serializable
@Serializable
data class ChapterListResponse(
val limit: Int,
val offset: Int,
val total: Int,
val results: List<ChapterResponse>
)
@Serializable
data class ChapterResponse(
val result: String,
val data: NetworkChapter,
val relationships: List<Relationships>
)
@Serializable
data class NetworkChapter(
val id: String,
val type: String,
val attributes: ChapterAttributes,
)
@Serializable
data class ChapterAttributes(
val title: String?,
val volume: String?,
val chapter: String?,
val translatedLanguage: String,
val publishAt: String,
val data: List<String>,
val dataSaver: List<String>,
val hash: String,
)
@Serializable
data class AtHomeResponse(
val baseUrl: String
)
@Serializable
data class GroupListResponse(
val limit: Int,
val offset: Int,
val total: Int,
val results: List<GroupResponse>
)
@Serializable
data class GroupResponse(
val result: String,
val data: GroupData,
)
@Serializable
data class GroupData(
val id: String,
val attributes: GroupAttributes,
)
@Serializable
data class GroupAttributes(
val name: String,
)

View File

@ -1,10 +0,0 @@
package exh.md.handlers.serializers
import kotlinx.serialization.Serializable
@Serializable
data class ImageReportResult(
val url: String,
val success: Boolean,
val bytes: Int?
)

View File

@ -1,11 +0,0 @@
package exh.md.handlers.serializers
import kotlinx.serialization.Serializable
@Serializable
data class ListCallResponse<T>(
val limit: Int,
val offset: Int,
val total: Int,
val results: List<T>
)

View File

@ -1,112 +0,0 @@
package exh.md.handlers.serializers
import kotlinx.serialization.Serializable
@Serializable
data class MangaListResponse(
val limit: Int,
val offset: Int,
val total: Int,
val results: List<MangaResponse>
)
@Serializable
data class MangaResponse(
val result: String,
val data: NetworkManga,
val relationships: List<Relationships>
)
@Serializable
data class NetworkManga(val id: String, val type: String, val attributes: NetworkMangaAttributes)
@Serializable
data class NetworkMangaAttributes(
val title: Map<String, String>,
val altTitles: List<Map<String, String>>,
val description: Map<String, String>,
val links: Map<String, String>?,
val originalLanguage: String,
val lastVolume: String?,
val lastChapter: String?,
val contentRating: String?,
val publicationDemographic: String?,
val status: String?,
val year: Int?,
val tags: List<TagsSerializer>
)
@Serializable
data class TagsSerializer(
val id: String,
val attributes: TagAttributes
)
@Serializable
data class TagAttributes(
val name: Map<String, String>
)
@Serializable
data class Relationships(
val id: String,
val type: String,
)
@Serializable
data class AuthorResponseList(
val results: List<AuthorResponse>,
)
@Serializable
data class AuthorResponse(
val result: String,
val data: NetworkAuthor,
)
@Serializable
data class NetworkAuthor(
val id: String,
val attributes: AuthorAttributes,
)
@Serializable
data class AuthorAttributes(
val name: String,
)
@Serializable
data class UpdateReadingStatus(
val status: String?
)
@Serializable
data class MangaStatusResponse(
val status: String?
)
@Serializable
data class MangaStatusListResponse(
val statuses: Map<String, String?>
)
@Serializable
data class CoverListResponse(
val results: List<CoverResponse>,
)
@Serializable
data class CoverResponse(
val data: Cover,
val relationships: List<Relationships>
)
@Serializable
data class Cover(
val attributes: CoverAttributes,
)
@Serializable
data class CoverAttributes(
val fileName: String,
)

View File

@ -1,13 +0,0 @@
package exh.md.handlers.serializers
import kotlinx.serialization.Serializable
@Serializable
data class NetworkFollowed(
val code: Int,
val message: String = "",
val data: List<FollowedSerializer>? = null
)
@Serializable
data class FollowedSerializer(val mangaId: String, val mangaTitle: String, val followType: Int)

View File

@ -2,50 +2,32 @@ package exh.md.network
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.mdlist.MdList
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.lang.withIOContext
import exh.log.xLogE
import exh.log.xLogI
import exh.md.handlers.serializers.CheckTokenResponse
import exh.md.handlers.serializers.LoginBodyToken
import exh.md.handlers.serializers.LoginRequest
import exh.md.handlers.serializers.LoginResponse
import exh.md.handlers.serializers.RefreshTokenRequest
import exh.md.handlers.serializers.ResultResponse
import exh.md.dto.LoginRequestDto
import exh.md.dto.RefreshTokenDto
import exh.md.service.MangaDexAuthService
import exh.md.utils.MdUtil
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
class MangaDexLoginHelper(val client: OkHttpClient, val preferences: PreferencesHelper, val mdList: MdList) {
suspend fun isAuthenticated(authHeaders: Headers): Boolean {
val response = client.newCall(GET(MdUtil.checkTokenUrl, authHeaders, CacheControl.FORCE_NETWORK)).await()
val body = response.parseAs<CheckTokenResponse>(MdUtil.jsonParser)
return body.isAuthenticated
class MangaDexLoginHelper(val authServiceLazy: Lazy<MangaDexAuthService>, val preferences: PreferencesHelper, val mdList: MdList) {
val authService by authServiceLazy
suspend fun isAuthenticated(): Boolean {
return runCatching { authService.checkToken().isAuthenticated }
.getOrElse { e ->
xLogE("error authenticating", e)
false
}
}
suspend fun refreshToken(authHeaders: Headers): Boolean {
suspend fun refreshToken(): Boolean {
val refreshToken = MdUtil.refreshToken(preferences, mdList)
if (refreshToken.isNullOrEmpty()) {
return false
}
val result = RefreshTokenRequest(refreshToken)
val jsonString = MdUtil.jsonParser.encodeToString(result)
val postResult = client.newCall(
POST(
MdUtil.refreshTokenUrl,
authHeaders,
jsonString.toRequestBody("application/json".toMediaType())
)
).await()
val refresh = runCatching {
val jsonResponse = postResult.parseAs<LoginResponse>(MdUtil.jsonParser)
val jsonResponse = authService.refreshToken(RefreshTokenDto(refreshToken))
preferences.trackToken(mdList).set(MdUtil.jsonParser.encodeToString(jsonResponse.token))
}
return refresh.isSuccess
@ -54,56 +36,32 @@ class MangaDexLoginHelper(val client: OkHttpClient, val preferences: Preferences
suspend fun login(
username: String,
password: String,
): LoginResult {
): Boolean {
return withIOContext {
val loginRequest = LoginRequest(username, password)
val loginRequest = LoginRequestDto(username, password)
val loginResult = runCatching { authService.login(loginRequest) }
val jsonString = MdUtil.jsonParser.encodeToString(loginRequest)
val postResult = runCatching {
client.newCall(
POST(
MdUtil.loginUrl,
Headers.Builder().build(),
jsonString.toRequestBody("application/json".toMediaType())
)
).await()
}
val response = postResult.getOrNull() ?: return@withIOContext LoginResult.Failure(postResult.exceptionOrNull())
// if it fails to parse then login failed
val loginResponse = try {
response.parseAs<LoginResponse>(MdUtil.jsonParser)
} catch (e: SerializationException) {
null
}
if (response.code == 200 && loginResponse != null && loginResponse.result == "ok") {
LoginResult.Success(loginResponse.token)
} else {
LoginResult.Failure()
}
val loginResponseDto = loginResult.getOrNull()
MdUtil.updateLoginToken(
loginResponseDto?.token,
preferences,
mdList
)
loginResponseDto != null
}
}
sealed class LoginResult {
data class Failure(val e: Throwable? = null) : LoginResult()
data class Success(val token: LoginBodyToken) : LoginResult()
}
suspend fun login(): LoginResult {
suspend fun login(): Boolean {
val username = preferences.trackUsername(mdList)
val password = preferences.trackPassword(mdList)
if (username.isNullOrBlank() || password.isNullOrBlank()) {
xLogI("No username or password stored, can't login")
return LoginResult.Failure()
return false
}
return login(username, password)
}
suspend fun logout(authHeaders: Headers): Boolean {
val response = client.newCall(GET(MdUtil.logoutUrl, authHeaders, CacheControl.FORCE_NETWORK)).await()
val body = response.parseAs<ResultResponse>(MdUtil.jsonParser)
return body.result == "ok"
suspend fun logout(): Boolean {
return authService.logout().result == "ok"
}
}

View File

@ -1,12 +1,9 @@
package exh.md.network
import exh.log.xLogD
import exh.log.xLogI
import exh.log.xLogW
import exh.md.utils.MdUtil
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Headers
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
@ -14,6 +11,7 @@ import okhttp3.Route
class TokenAuthenticator(private val loginHelper: MangaDexLoginHelper) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
xLogI("Detected Auth error ${response.code} on ${response.request.url}")
val token = refreshToken(loginHelper)
return if (token != null) {
response.request.newBuilder().header("Authorization", token).build()
@ -27,24 +25,7 @@ class TokenAuthenticator(private val loginHelper: MangaDexLoginHelper) : Authent
var validated = false
runBlocking {
val checkTokenResult = runCatching {
loginHelper.isAuthenticated(
MdUtil.getAuthHeaders(
Headers.Builder().build(),
loginHelper.preferences,
loginHelper.mdList
)
)
}
val checkToken = if (checkTokenResult.isSuccess) {
checkTokenResult.getOrNull() ?: false
} else {
val e = checkTokenResult.exceptionOrNull()
if (e is NoSessionException) {
this@TokenAuthenticator.xLogD("Session token does not exist")
}
false
}
val checkToken = loginHelper.isAuthenticated()
if (checkToken) {
this@TokenAuthenticator.xLogI("Token is valid, other thread must have refreshed it")
@ -52,37 +33,16 @@ class TokenAuthenticator(private val loginHelper: MangaDexLoginHelper) : Authent
}
if (validated.not()) {
this@TokenAuthenticator.xLogI("Token is invalid trying to refresh")
val result = runCatching {
validated = loginHelper.refreshToken(
MdUtil.getAuthHeaders(
Headers.Builder().build(),
loginHelper.preferences,
loginHelper.mdList
)
)
}
if (result.isFailure) {
result.exceptionOrNull()?.let {
this@TokenAuthenticator.xLogW("Error refreshing token", it)
}
}
validated = loginHelper.refreshToken()
}
if (validated.not()) {
this@TokenAuthenticator.xLogI("Did not refresh token, trying to login")
val loginResult = loginHelper.login()
validated = if (loginResult is MangaDexLoginHelper.LoginResult.Success) {
MdUtil.updateLoginToken(
loginResult.token,
loginHelper.preferences,
loginHelper.mdList
)
true
} else false
validated = loginHelper.login()
}
}
return when {
validated -> "bearer: ${MdUtil.sessionToken(loginHelper.preferences, loginHelper.mdList)!!}"
validated -> "Bearer ${MdUtil.sessionToken(loginHelper.preferences, loginHelper.mdList)!!}"
else -> null
}
}

View File

@ -0,0 +1,185 @@
package exh.md.service
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.mdlist.MdList
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs
import exh.md.dto.CheckTokenDto
import exh.md.dto.LoginRequestDto
import exh.md.dto.LoginResponseDto
import exh.md.dto.LogoutDto
import exh.md.dto.MangaListDto
import exh.md.dto.ReadChapterDto
import exh.md.dto.ReadingStatusDto
import exh.md.dto.ReadingStatusMapDto
import exh.md.dto.RefreshTokenDto
import exh.md.dto.ResultDto
import exh.md.utils.MdApi
import exh.md.utils.MdUtil
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
class MangaDexAuthService(
private val client: OkHttpClient,
private val headers: Headers,
private val preferences: PreferencesHelper,
private val mdList: MdList
) {
fun getHeaders() = MdUtil.getAuthHeaders(
headers,
preferences,
mdList
)
suspend fun login(request: LoginRequestDto): LoginResponseDto {
return client.newCall(
POST(
MdApi.login,
body = MdUtil.encodeToBody(request),
cache = CacheControl.FORCE_NETWORK
)
).await().parseAs(MdUtil.jsonParser)
}
suspend fun logout(): LogoutDto {
return client.newCall(
POST(
MdApi.logout,
getHeaders(),
cache = CacheControl.FORCE_NETWORK
)
).await().parseAs(MdUtil.jsonParser)
}
suspend fun checkToken(): CheckTokenDto {
return client.newCall(
GET(
MdApi.checkToken,
getHeaders(),
CacheControl.FORCE_NETWORK
)
).await().parseAs(MdUtil.jsonParser)
}
suspend fun refreshToken(request: RefreshTokenDto): LoginResponseDto {
return client.newCall(
POST(
MdApi.refreshToken,
getHeaders(),
body = MdUtil.encodeToBody(request),
cache = CacheControl.FORCE_NETWORK
)
).await().parseAs(MdUtil.jsonParser)
}
// &includes[]=${MdConstants.Type.coverArt}
suspend fun userFollowList(offset: Int): MangaListDto {
return client.newCall(
GET(
"${MdApi.userFollows}?limit=100&offset=$offset",
getHeaders(),
CacheControl.FORCE_NETWORK
)
).await().parseAs(MdUtil.jsonParser)
}
suspend fun readingStatusForManga(mangaId: String): ReadingStatusDto {
return client.newCall(
GET(
"${MdApi.manga}/$mangaId/status",
getHeaders(),
CacheControl.FORCE_NETWORK
)
).await().parseAs(MdUtil.jsonParser)
}
suspend fun readChaptersForManga(mangaId: String): ReadChapterDto {
return client.newCall(
GET(
"${MdApi.manga}/$mangaId/read",
getHeaders(),
CacheControl.FORCE_NETWORK
)
).await().parseAs(MdUtil.jsonParser)
}
suspend fun updateReadingStatusForManga(
mangaId: String,
readingStatusDto: ReadingStatusDto,
): ResultDto {
return client.newCall(
POST(
"${MdApi.manga}/$mangaId/status",
getHeaders(),
body = MdUtil.encodeToBody(readingStatusDto),
cache = CacheControl.FORCE_NETWORK
)
).await().parseAs(MdUtil.jsonParser)
}
suspend fun readingStatusAllManga(): ReadingStatusMapDto {
return client.newCall(
GET(
MdApi.readingStatusForAllManga,
getHeaders(),
cache = CacheControl.FORCE_NETWORK
)
).await().parseAs(MdUtil.jsonParser)
}
suspend fun readingStatusByType(status: String): ReadingStatusMapDto {
return client.newCall(
GET(
"${MdApi.readingStatusForAllManga}?status=$status",
getHeaders(),
cache = CacheControl.FORCE_NETWORK
)
).await().parseAs(MdUtil.jsonParser)
}
suspend fun markChapterRead(chapterId: String): ResultDto {
return client.newCall(
POST(
"${MdApi.chapter}/$chapterId/read",
getHeaders(),
cache = CacheControl.FORCE_NETWORK
)
).await().parseAs(MdUtil.jsonParser)
}
suspend fun markChapterUnRead(chapterId: String): ResultDto {
return client.newCall(
Request.Builder()
.url("${MdApi.chapter}/$chapterId/read")
.delete()
.headers(getHeaders())
.cacheControl(CacheControl.FORCE_NETWORK)
.build()
).await().parseAs(MdUtil.jsonParser)
}
suspend fun followManga(mangaId: String): ResultDto {
return client.newCall(
POST(
"${MdApi.manga}/$mangaId/follow",
getHeaders(),
cache = CacheControl.FORCE_NETWORK
)
).await().parseAs(MdUtil.jsonParser)
}
suspend fun unfollowManga(mangaId: String): ResultDto {
return client.newCall(
Request.Builder()
.url("${MdApi.manga}/$mangaId/follow")
.delete()
.headers(getHeaders())
.cacheControl(CacheControl.FORCE_NETWORK)
.build()
).await().parseAs(MdUtil.jsonParser)
}
}

View File

@ -0,0 +1,107 @@
package exh.md.service
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs
import exh.md.dto.AtHomeDto
import exh.md.dto.AtHomeImageReportDto
import exh.md.dto.ChapterDto
import exh.md.dto.ChapterListDto
import exh.md.dto.MangaDto
import exh.md.dto.MangaListDto
import exh.md.dto.ResultDto
import exh.md.utils.MdApi
import exh.md.utils.MdConstants
import exh.md.utils.MdUtil
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
class MangaDexService(
private val client: OkHttpClient
) {
suspend fun viewMangas(
ids: List<String>
): MangaListDto {
return client.newCall(
GET(
MdApi.manga.toHttpUrl().newBuilder().apply {
addQueryParameter("includes[]", MdConstants.Types.coverArt)
addQueryParameter("limit", ids.size.toString())
ids.forEach {
addQueryParameter("ids[]", it)
}
}.build().toString(),
cache = CacheControl.FORCE_NETWORK
)
).await().parseAs(MdUtil.jsonParser)
}
suspend fun viewManga(
id: String
): MangaDto {
return client.newCall(
GET(
"${MdApi.manga}/$id?includes[]=${MdConstants.Types.coverArt}&includes[]=${MdConstants.Types.author}&includes[]=${MdConstants.Types.artist}",
cache = CacheControl.FORCE_NETWORK
)
).await().parseAs(MdUtil.jsonParser)
}
suspend fun viewChapters(
id: String,
translatedLanguage: String,
offset: Int,
): ChapterListDto {
val url = "${MdApi.manga}/$id/feed?limit=500&includes[]=${MdConstants.Types.scanlator}&order[volume]=desc&order[chapter]=desc".toHttpUrl()
.newBuilder()
.apply {
addQueryParameter("translatedLanguage[]", translatedLanguage)
addQueryParameter("offset", offset.toString())
}.build()
.toString()
return client.newCall(
GET(
url,
cache = CacheControl.FORCE_NETWORK
)
).await().parseAs(MdUtil.jsonParser)
}
suspend fun viewChapter(id: String): ChapterDto {
return client.newCall(GET("${MdApi.chapter}/$id", cache = CacheControl.FORCE_NETWORK))
.await()
.parseAs(MdUtil.jsonParser)
}
suspend fun randomManga(): MangaDto {
return client.newCall(GET("${MdApi.manga}/random", cache = CacheControl.FORCE_NETWORK))
.await()
.parseAs(MdUtil.jsonParser)
}
suspend fun atHomeImageReport(atHomeImageReportDto: AtHomeImageReportDto): ResultDto {
return client.newCall(
POST(
MdConstants.atHomeReportUrl,
body = MdUtil.encodeToBody(atHomeImageReportDto),
cache = CacheControl.FORCE_NETWORK
)
).await().parseAs(MdUtil.jsonParser)
}
suspend fun getAtHomeServer(
headers: Headers,
chapterId: String,
forcePort443: Boolean,
): Pair<String, AtHomeDto> {
val url = "${MdApi.atHomeServer}/$chapterId?forcePort443=$forcePort443"
return client.newCall(GET(url, headers, CacheControl.FORCE_NETWORK))
.await()
.let { it.request.url.toUrl().toString() to it.parseAs(MdUtil.jsonParser) }
}
}

View File

@ -0,0 +1,20 @@
package exh.md.service
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs
import exh.md.dto.SimilarMangaDto
import exh.md.utils.MdUtil
import okhttp3.OkHttpClient
class SimilarService(
private val client: OkHttpClient
) {
suspend fun getSimilarManga(mangaId: String): SimilarMangaDto {
return client.newCall(
GET(
"${MdUtil.similarBaseApi}$mangaId.json"
)
).await().parseAs()
}
}

View File

@ -11,6 +11,8 @@ enum class FollowStatus(val int: Int) {
DROPPED(5),
RE_READING(6);
fun toDex(): String = this.name.lowercase(Locale.US)
companion object {
fun fromDex(value: String?): FollowStatus = values().firstOrNull { it.name.lowercase(Locale.US) == value } ?: UNFOLLOWED
fun fromInt(value: Int): FollowStatus = values().firstOrNull { it.int == value } ?: UNFOLLOWED

View File

@ -0,0 +1,19 @@
package exh.md.utils
object MdApi {
const val baseUrl = "https://api.mangadex.org"
const val login = "$baseUrl/auth/login"
const val checkToken = "$baseUrl/auth/check"
const val refreshToken = "$baseUrl/auth/refresh"
const val logout = "$baseUrl/auth/logout"
const val manga = "$baseUrl/manga"
const val chapter = "$baseUrl/chapter"
const val group = "$baseUrl/group"
const val author = "$baseUrl/author"
const val chapterImageServer = "$baseUrl/at-home/server"
const val userFollows = "$baseUrl/user/follows/manga"
const val readingStatusForAllManga = "$baseUrl/manga/status"
const val atHomeServer = "$baseUrl/at-home/server"
const val legacyMapping = "$baseUrl/legacy/mapping"
}

View File

@ -0,0 +1,19 @@
package exh.md.utils
import exh.util.minutes
object MdConstants {
const val baseUrl = "https://mangadex.org"
const val cdnUrl = "https://uploads.mangadex.org"
const val atHomeReportUrl = "https://api.mangadex.network/report"
object Types {
const val author = "author"
const val artist = "artist"
const val coverArt = "cover_art"
const val manga = "manga"
const val scanlator = "scanlation_group"
}
val mdAtHomeTokenLifespan = 5.minutes.inWholeMilliseconds
}

View File

@ -0,0 +1,17 @@
package exh.md.utils
import exh.md.dto.ListCallDto
import exh.util.under
suspend fun <T> mdListCall(request: suspend (offset: Int) -> ListCallDto<T>): List<T> {
val results = mutableListOf<T>()
var offset = 0
do {
val list = request(offset)
results += list.results
offset += list.limit
} while (offset under list.total)
return results
}

View File

@ -2,36 +2,26 @@ package exh.md.utils
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.mdlist.MdList
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.all.MangaDex
import exh.log.xLogD
import exh.log.xLogE
import exh.md.handlers.serializers.AtHomeResponse
import exh.md.handlers.serializers.CoverListResponse
import exh.md.handlers.serializers.CoverResponse
import exh.md.handlers.serializers.ListCallResponse
import exh.md.handlers.serializers.LoginBodyToken
import exh.md.handlers.serializers.MangaResponse
import exh.md.dto.LoginBodyTokenDto
import exh.md.dto.MangaDto
import exh.md.network.NoSessionException
import exh.source.getMainSource
import exh.util.floor
import exh.util.nullIfBlank
import exh.util.nullIfZero
import exh.util.under
import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.CacheControl
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.jsoup.parser.Parser
import tachiyomi.source.model.MangaInfo
import uy.kohesive.injekt.Injekt
@ -45,35 +35,7 @@ class MdUtil {
companion object {
const val cdnUrl = "https://uploads.mangadex.org"
const val baseUrl = "https://mangadex.org"
const val apiUrl = "https://api.mangadex.org"
const val imageUrlCacheNotFound = "https://cdn.statically.io/img/raw.githubusercontent.com/CarlosEsco/Neko/master/.github/manga_cover_not_found.png"
const val atHomeUrl = "$apiUrl/at-home/server"
const val coverUrl = "$apiUrl/cover"
const val chapterUrl = "$apiUrl/chapter/"
const val chapterSuffix = "/chapter/"
const val checkTokenUrl = "$apiUrl/auth/check"
const val refreshTokenUrl = "$apiUrl/auth/refresh"
const val loginUrl = "$apiUrl/auth/login"
const val logoutUrl = "$apiUrl/auth/logout"
const val groupUrl = "$apiUrl/group"
const val authorUrl = "$apiUrl/author"
const val randomMangaUrl = "$apiUrl/manga/random"
const val mangaUrl = "$apiUrl/manga"
const val mangaStatus = "$apiUrl/manga/status"
const val userFollows = "$apiUrl/user/follows/manga"
fun updateReadingStatusUrl(id: String) = "$apiUrl/manga/$id/status"
fun mangaFeedUrl(id: String, offset: Int, language: String): String {
return "$mangaUrl/$id/feed".toHttpUrl().newBuilder().apply {
addQueryParameter("limit", "500")
addQueryParameter("offset", offset.toString())
addQueryParameter("translatedLanguage[]", language)
addQueryParameter("order[volume]", "desc")
addQueryParameter("order[chapter]", "desc")
}.build().toString()
}
fun coverUrl(mangaId: String, coverId: String) = "$apiUrl/cover?manga[]=$mangaId&ids[]=$coverId"
const val similarCacheMapping = "https://api.similarmanga.com/mapping/mdex2search.csv"
const val similarCacheMangas = "https://api.similarmanga.com/manga/"
@ -83,7 +45,7 @@ class MdUtil {
const val reportUrl = "https://api.mangadex.network/report"
const val mdAtHomeTokenLifespan = 10 * 60 * 1000
const val mangaLimit = 25
const val mangaLimit = 20
/**
* Get the manga offset pages are 1 based, so subtract 1
@ -217,10 +179,6 @@ class MdUtil {
return "/manga/$mangaUuid"
}
fun formThumbUrl(mangaUrl: String): String {
return "https://coverapi.orell.dev/api/v1/mdaltimage/manga/${getMangaId(mangaUrl)}/cover"
}
// Get the ID from the manga url
fun getMangaId(url: String): String = url.trimEnd('/').substringAfterLast("/")
@ -298,76 +256,33 @@ class MdUtil {
return null
}
fun atHomeUrlHostUrl(requestUrl: String, client: OkHttpClient, headers: Headers, cacheControl: CacheControl): String {
val atHomeRequest = GET(requestUrl, headers, cache = cacheControl)
val atHomeResponse = client.newCall(atHomeRequest).execute()
return jsonParser.decodeFromString<AtHomeResponse>(atHomeResponse.body!!.string()).baseUrl
}
val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss+SSS", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
fun parseDate(dateAsString: String): Long =
dateFormatter.parse(dateAsString)?.time ?: 0
fun createMangaEntry(json: MangaResponse, lang: String, coverUrl: String?): MangaInfo {
fun createMangaEntry(json: MangaDto, lang: String): MangaInfo {
return MangaInfo(
key = buildMangaUrl(json.data.id),
title = cleanString(json.data.attributes.title[lang] ?: json.data.attributes.title["en"]!!),
cover = coverUrl.orEmpty()
cover = json.relationships
.firstOrNull { relationshipDto -> relationshipDto.type == MdConstants.Types.coverArt }
?.attributes
?.fileName
?.let { coverFileName ->
cdnCoverUrl(json.data.id, coverFileName)
}.orEmpty()
)
}
suspend fun getCoverUrl(dexId: String, coverId: String?, client: OkHttpClient): String {
coverId ?: return ""
val coverResponse = client.newCall(GET("$coverUrl/$coverId"))
.await().parseAs<CoverResponse>()
val fileName = coverResponse.data.attributes.fileName
fun cdnCoverUrl(dexId: String, fileName: String): String {
return "$cdnUrl/covers/$dexId/$fileName"
}
suspend fun getCoversFromMangaList(mangaResponseList: List<MangaResponse>, client: OkHttpClient): Map<String, String> {
val idsAndCoverIds = mangaResponseList.mapNotNull { mangaResponse ->
val mangaId = mangaResponse.data.id
val coverId = mangaResponse.relationships.firstOrNull { relationship ->
relationship.type.equals("cover_art", true)
}?.id
if (coverId == null) {
null
} else {
Pair(mangaId, coverId)
}
}.toMap()
return runCatching {
getBatchCoverUrls(idsAndCoverIds, client)
}.getOrNull()!!
}
private suspend fun getBatchCoverUrls(ids: Map<String, String>, client: OkHttpClient): Map<String, String> {
try {
val url = coverUrl.toHttpUrl().newBuilder().apply {
ids.values.forEach { coverArtId ->
addQueryParameter("ids[]", coverArtId)
}
addQueryParameter("limit", ids.size.toString())
}.build().toString()
val coverList = client.newCall(GET(url)).await().parseAs<CoverListResponse>(jsonParser)
return coverList.results.map { coverResponse ->
val fileName = coverResponse.data.attributes.fileName
val mangaId = coverResponse.relationships.first { it.type.equals("manga", true) }.id
val thumbnailUrl = "$cdnUrl/covers/$mangaId/$fileName"
Pair(mangaId, thumbnailUrl)
}.toMap()
} catch (e: Exception) {
xLogE("Error getting covers", e)
throw e
}
}
fun getLoginBody(preferences: PreferencesHelper, mdList: MdList) = preferences.trackToken(mdList).get().nullIfBlank()?.let {
try {
jsonParser.decodeFromString<LoginBodyToken>(it)
jsonParser.decodeFromString<LoginBodyTokenDto>(it)
} catch (e: SerializationException) {
xLogD("Unable to load login body")
null
@ -378,8 +293,10 @@ class MdUtil {
fun refreshToken(preferences: PreferencesHelper, mdList: MdList) = getLoginBody(preferences, mdList)?.refresh
fun updateLoginToken(token: LoginBodyToken, preferences: PreferencesHelper, mdList: MdList) {
preferences.trackToken(mdList).set(jsonParser.encodeToString(token))
fun updateLoginToken(token: LoginBodyTokenDto?, preferences: PreferencesHelper, mdList: MdList) {
if (token != null) {
preferences.trackToken(mdList).set(jsonParser.encodeToString(token))
} else preferences.trackToken(mdList).delete()
}
fun getAuthHeaders(headers: Headers, preferences: PreferencesHelper, mdList: MdList) =
@ -400,27 +317,17 @@ class MdUtil {
val disabledSourceIds = preferences.disabledSources().get()
return sourceManager.getVisibleOnlineSources()
.asSequence()
.map { it.getMainSource() }
.filterIsInstance<MangaDex>()
.filter { it.lang in languages }
.filterNot { it.id.toString() in disabledSourceIds }
.toList()
}
inline fun <reified T> encodeToBody(body: T): RequestBody {
return jsonParser.encodeToString(body)
.toRequestBody("application/json".toMediaType())
}
}
}
suspend inline fun <reified T> OkHttpClient.mdListCall(request: (offset: Int) -> Request): List<T> {
val results = mutableListOf<T>()
var offset = 0
do {
val response = newCall(request(offset)).await()
if (response.code == 204) {
break
}
val mangaListResponse = response.parseAs<ListCallResponse<T>>(MdUtil.jsonParser)
results += mangaListResponse.results
offset += mangaListResponse.limit
} while (offset under mangaListResponse.total)
return results
}