diff --git a/lib-multisrc/anilist/build.gradle.kts b/lib-multisrc/anilist/build.gradle.kts new file mode 100644 index 00000000..dc076cc3 --- /dev/null +++ b/lib-multisrc/anilist/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("lib-multisrc") +} + +baseVersionCode = 1 diff --git a/lib-multisrc/anilist/src/eu/kanade/tachiyomi/multisrc/anilist/AniListAnimeHttpSource.kt b/lib-multisrc/anilist/src/eu/kanade/tachiyomi/multisrc/anilist/AniListAnimeHttpSource.kt new file mode 100644 index 00000000..0f57cf33 --- /dev/null +++ b/lib-multisrc/anilist/src/eu/kanade/tachiyomi/multisrc/anilist/AniListAnimeHttpSource.kt @@ -0,0 +1,167 @@ +package eu.kanade.tachiyomi.multisrc.anilist + +import eu.kanade.tachiyomi.animesource.model.AnimeFilterList +import eu.kanade.tachiyomi.animesource.model.AnimesPage +import eu.kanade.tachiyomi.animesource.model.SAnime +import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.util.parseAs +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.FormBody +import okhttp3.Request +import okhttp3.Response +import uy.kohesive.injekt.injectLazy + +abstract class AniListAnimeHttpSource : AnimeHttpSource() { + override val supportsLatest = true + val json by injectLazy() + + /* =============================== Mapping AniList <> Source =============================== */ + abstract fun mapAnimeDetailUrl(animeId: Int): String + + abstract fun mapAnimeId(animeDetailUrl: String): Int + + open fun getPreferredTitleLanguage(): TitleLanguage { + return TitleLanguage.ROMAJI + } + + /* ===================================== Popular Anime ===================================== */ + override fun popularAnimeRequest(page: Int): Request { + return buildAnimeListRequest( + query = ANIME_LIST_QUERY, + variables = AnimeListVariables( + page = page, + sort = AnimeListVariables.MediaSort.POPULARITY_DESC, + ), + ) + } + + override fun popularAnimeParse(response: Response): AnimesPage { + return parseAnimeListResponse(response) + } + + /* ===================================== Latest Anime ===================================== */ + override fun latestUpdatesRequest(page: Int): Request { + return buildAnimeListRequest( + query = LATEST_ANIME_LIST_QUERY, + variables = AnimeListVariables( + page = page, + sort = AnimeListVariables.MediaSort.START_DATE_DESC, + ), + ) + } + + override fun latestUpdatesParse(response: Response): AnimesPage { + return parseAnimeListResponse(response) + } + + /* ===================================== Search Anime ===================================== */ + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { + return buildAnimeListRequest( + query = ANIME_LIST_QUERY, + variables = AnimeListVariables( + page = page, + sort = AnimeListVariables.MediaSort.SEARCH_MATCH, + search = query.ifBlank { null }, + ), + ) + } + + override fun searchAnimeParse(response: Response): AnimesPage { + return parseAnimeListResponse(response) + } + + /* ===================================== Anime Details ===================================== */ + override fun animeDetailsRequest(anime: SAnime): Request { + return buildRequest( + query = ANIME_DETAILS_QUERY, + variables = json.encodeToString(AnimeDetailsVariables(mapAnimeId(anime.url))), + ) + } + + override fun animeDetailsParse(response: Response): SAnime { + val media = response.parseAs().data.media + + return media.toSAnime() + } + + override fun getAnimeUrl(anime: SAnime): String { + return anime.url + } + + /* ==================================== AniList Utility ==================================== */ + private fun buildAnimeListRequest( + query: String, + variables: AnimeListVariables, + ): Request { + return buildRequest(query, json.encodeToString(variables)) + } + + private fun buildRequest(query: String, variables: String): Request { + val requestBody = FormBody.Builder() + .add("query", query) + .add("variables", variables) + .build() + + return POST(url = "https://graphql.anilist.co", body = requestBody) + } + + private fun parseAnimeListResponse(response: Response): AnimesPage { + val page = response.parseAs().data.page + + return AnimesPage( + animes = page.media.map { it.toSAnime() }, + hasNextPage = page.pageInfo.hasNextPage, + ) + } + + private fun AniListMedia.toSAnime(): SAnime { + val otherNames = when (getPreferredTitleLanguage()) { + TitleLanguage.ROMAJI -> listOfNotNull(title.english, title.native) + TitleLanguage.ENGLISH -> listOfNotNull(title.romaji, title.native) + TitleLanguage.NATIVE -> listOfNotNull(title.romaji, title.english) + } + val newDescription = buildString { + append( + description + ?.replace("
\n
", "\n") + ?.replace("<.*?>".toRegex(), ""), + ) + if (otherNames.isNotEmpty()) { + appendLine() + appendLine() + append("Other name(s): ${otherNames.joinToString(", ")}") + } + } + val media = this + + return SAnime.create().apply { + url = mapAnimeDetailUrl(media.id) + title = parseTitle(media.title) + author = media.studios.nodes.joinToString(", ") { it.name } + description = newDescription + genre = media.genres.joinToString(", ") + status = when (media.status) { + AniListMedia.Status.RELEASING -> SAnime.ONGOING + AniListMedia.Status.FINISHED -> SAnime.COMPLETED + } + thumbnail_url = media.coverImage.large + initialized = true + } + } + + private fun parseTitle(title: AniListMedia.Title): String { + return when (getPreferredTitleLanguage()) { + TitleLanguage.ROMAJI -> title.romaji + TitleLanguage.ENGLISH -> title.english ?: title.romaji + TitleLanguage.NATIVE -> title.native ?: title.romaji + } + } + + enum class TitleLanguage { + ROMAJI, + ENGLISH, + NATIVE, + } +} diff --git a/lib-multisrc/anilist/src/eu/kanade/tachiyomi/multisrc/anilist/AniListRequest.kt b/lib-multisrc/anilist/src/eu/kanade/tachiyomi/multisrc/anilist/AniListRequest.kt new file mode 100644 index 00000000..00fcfac0 --- /dev/null +++ b/lib-multisrc/anilist/src/eu/kanade/tachiyomi/multisrc/anilist/AniListRequest.kt @@ -0,0 +1,73 @@ +package eu.kanade.tachiyomi.multisrc.anilist + +import kotlinx.serialization.Serializable + +internal const val MEDIA_QUERY = """ + id + title { + romaji + english + native + } + coverImage { + large + } + description + status + genres + studios(isMain: true) { + nodes { + name + } + } +""" + +internal const val ANIME_LIST_QUERY = """ + query (${"$"}page: Int, ${"$"}sort: [MediaSort], ${"$"}search: String) { + Page(page: ${"$"}page, perPage: 30) { + pageInfo { + hasNextPage + } + media(type: ANIME, sort: ${"$"}sort, search: ${"$"}search, status_in: [RELEASING, FINISHED], countryOfOrigin: "JP", isAdult: false) { + $MEDIA_QUERY + } + } + } +""" + +internal const val LATEST_ANIME_LIST_QUERY = """ + query (${"$"}page: Int, ${"$"}sort: [MediaSort], ${"$"}search: String) { + Page(page: ${"$"}page, perPage: 30) { + pageInfo { + hasNextPage + } + media(type: ANIME, sort: ${"$"}sort, search: ${"$"}search, status_in: [RELEASING, FINISHED], countryOfOrigin: "JP", isAdult: false, startDate_greater: 1, episodes_greater: 1) { + $MEDIA_QUERY + } + } + } +""" + +internal const val ANIME_DETAILS_QUERY = """ + query (${"$"}id: Int) { + Media(id: ${"$"}id) { + $MEDIA_QUERY + } + } +""" + +@Serializable +internal data class AnimeListVariables( + val page: Int, + val sort: MediaSort, + val search: String? = null, +) { + enum class MediaSort { + POPULARITY_DESC, + SEARCH_MATCH, + START_DATE_DESC, + } +} + +@Serializable +internal data class AnimeDetailsVariables(val id: Int) diff --git a/lib-multisrc/anilist/src/eu/kanade/tachiyomi/multisrc/anilist/AniListResponse.kt b/lib-multisrc/anilist/src/eu/kanade/tachiyomi/multisrc/anilist/AniListResponse.kt new file mode 100644 index 00000000..d445e8e9 --- /dev/null +++ b/lib-multisrc/anilist/src/eu/kanade/tachiyomi/multisrc/anilist/AniListResponse.kt @@ -0,0 +1,57 @@ +package eu.kanade.tachiyomi.multisrc.anilist + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class AniListAnimeListResponse(val data: Data) { + @Serializable + data class Data(@SerialName("Page") val page: Page) { + @Serializable + data class Page( + val pageInfo: PageInfo, + val media: List, + ) { + @Serializable + data class PageInfo(val hasNextPage: Boolean) + } + } +} + +@Serializable +internal data class AniListAnimeDetailsResponse(val data: Data) { + @Serializable + data class Data(@SerialName("Media") val media: AniListMedia) +} + +@Serializable +internal data class AniListMedia( + val id: Int, + val title: Title, + val coverImage: CoverImage, + val description: String?, + val status: Status, + val genres: List, + val studios: Studios, +) { + @Serializable + data class Title( + val romaji: String, + val english: String?, + val native: String?, + ) + + @Serializable + data class CoverImage(val large: String) + + enum class Status { + RELEASING, + FINISHED, + } + + @Serializable + data class Studios(val nodes: List) { + @Serializable + data class Node(val name: String) + } +}