diff --git a/src/en/yugenanime/AndroidManifest.xml b/src/en/yugenanime/AndroidManifest.xml new file mode 100644 index 00000000..5f63e27c --- /dev/null +++ b/src/en/yugenanime/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/src/en/yugenanime/build.gradle b/src/en/yugenanime/build.gradle new file mode 100644 index 00000000..435264d0 --- /dev/null +++ b/src/en/yugenanime/build.gradle @@ -0,0 +1,7 @@ +ext { + extName = 'YugenAnime' + extClass = '.YugenAnime' + extVersionCode = 2 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/yugenanime/res/mipmap-hdpi/ic_launcher.png b/src/en/yugenanime/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..f30375d4 Binary files /dev/null and b/src/en/yugenanime/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/yugenanime/res/mipmap-mdpi/ic_launcher.png b/src/en/yugenanime/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..18216fbc Binary files /dev/null and b/src/en/yugenanime/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/yugenanime/res/mipmap-xhdpi/ic_launcher.png b/src/en/yugenanime/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..d73bdcdf Binary files /dev/null and b/src/en/yugenanime/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/yugenanime/res/mipmap-xxhdpi/ic_launcher.png b/src/en/yugenanime/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..39784209 Binary files /dev/null and b/src/en/yugenanime/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/yugenanime/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/yugenanime/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..08bec54d Binary files /dev/null and b/src/en/yugenanime/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/yugenanime/src/eu/kanade/tachiyomi/animeextension/en/yugenanime/YugenAnime.kt b/src/en/yugenanime/src/eu/kanade/tachiyomi/animeextension/en/yugenanime/YugenAnime.kt new file mode 100644 index 00000000..158dae7c --- /dev/null +++ b/src/en/yugenanime/src/eu/kanade/tachiyomi/animeextension/en/yugenanime/YugenAnime.kt @@ -0,0 +1,406 @@ +package eu.kanade.tachiyomi.animeextension.en.yugenanime + +import eu.kanade.tachiyomi.animesource.model.AnimeFilter +import eu.kanade.tachiyomi.animesource.model.AnimeFilterList +import eu.kanade.tachiyomi.animesource.model.SAnime +import eu.kanade.tachiyomi.animesource.model.SEpisode +import eu.kanade.tachiyomi.animesource.model.Video +import eu.kanade.tachiyomi.animesource.online.ParsedAnimeHttpSource +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.util.asJsoup +import eu.kanade.tachiyomi.util.parseAs +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.net.URI +import java.text.SimpleDateFormat +import java.util.Locale + +class YugenAnime : ParsedAnimeHttpSource() { + + override val name = "YugenAnime" + + override val baseUrl = "https://yugenanime.sx" + + override val lang = "en" + + override val supportsLatest = true + + override val client = OkHttpClient() + + // ============================== Popular =============================== + override fun popularAnimeRequest(page: Int): Request { + val url = "$baseUrl/discover/?page=$page" + return GET(url, headers) + } + + override fun popularAnimeSelector(): String = "div.cards-grid a.anime-meta" + + override fun popularAnimeFromElement(element: Element): SAnime { + val anime = SAnime.create() + anime.title = element.attr("title").ifBlank { element.select("span.anime-name").text() } + anime.setUrlWithoutDomain(element.attr("href")) + anime.thumbnail_url = element.selectFirst("img.lozad")?.attr("data-src") + return anime + } + + override fun popularAnimeNextPageSelector(): String = "div.sidepanel--content > nav > ul > li:nth-child(7) > a" + + // =============================== Latest =============================== + override fun latestUpdatesRequest(page: Int): Request { + val url = "$baseUrl/discover/?page=$page&sort=Newest+Addition" + return GET(url, headers) + } + + override fun latestUpdatesSelector(): String = "div.cards-grid a.anime-meta" + + override fun latestUpdatesFromElement(element: Element): SAnime { + val anime = SAnime.create() + anime.title = element.attr("title").ifBlank { element.select("span.anime-name").text() } + anime.setUrlWithoutDomain(element.attr("href")) + anime.thumbnail_url = element.selectFirst("img.lozad")?.attr("data-src") + return anime + } + + override fun latestUpdatesNextPageSelector(): String = "ul.pagination li.next a" + + // =============================== Search =============================== + + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { + val filterList = if (filters.isEmpty()) getFilterList() else filters + val genreFilter = filterList.find { it is GenreFilter } as? GenreFilter + val sortFilter = filterList.find { it is SortFilter } as? SortFilter + val statusFilter = filterList.find { it is StatusFilter } as? StatusFilter + val yearFilter = filterList.find { it is YearFilter } as? YearFilter + val languageFilter = filterList.find { it is LanguageFilter } as? LanguageFilter + + val queryString = mutableListOf() + + genreFilter?.let { + val genrePart = it.toUriPart() + if (genrePart.isNotBlank()) { + queryString.add(genrePart) + } + } + + sortFilter?.let { if (it.state != 0) queryString.add(it.toUriPart()) } + statusFilter?.let { if (it.state != 0) queryString.add(it.toUriPart()) } + yearFilter?.let { if (it.state != 0) queryString.add(it.toUriPart()) } + languageFilter?.let { if (it.state != 0) queryString.add(it.toUriPart()) } + + val url = when { + query.isNotBlank() -> "$baseUrl/discover/?page=$page&q=$query${if (queryString.isNotEmpty()) "&${queryString.joinToString("&")}" else ""}" + queryString.isNotEmpty() -> "$baseUrl/discover/?page=$page&${queryString.joinToString("&")}" + else -> "$baseUrl/discover/?page=$page" + } + + return GET(url, headers) + } + + private open class UriPartFilter(displayName: String, val vals: Array>) : + AnimeFilter.Select(displayName, vals.map { it.first }.toTypedArray()) { + fun toUriPart() = vals[state].second + } + + private class StatusFilter : UriPartFilter( + "Status", + arrayOf( + Pair("Any", ""), + Pair("Not yet aired", "status=Not+yet+aired"), + Pair("Currently Airing", "status=Currently+Airing"), + Pair("Finished Airing", "status=Finished+Airing"), + ), + ) + + private class YearFilter : UriPartFilter( + "Year", + arrayOf( + Pair("Any", ""), + Pair("2024", "year=2024"), + Pair("2023", "year=2023"), + Pair("2022", "year=2022"), + ), + ) + + private class LanguageFilter : UriPartFilter( + "Language", + arrayOf( + Pair("Both", ""), + Pair("Sub", "language=Sub"), + Pair("Dub", "language=Dub"), + ), + ) + + private class GenreFilter : CheckBoxFilterList( + "Genres", + arrayOf( + Pair("Action", "genreIncluded=Action"), + Pair("Adventure", "genreIncluded=Adventure"), + Pair("Comedy", "genreIncluded=Comedy"), + Pair("Drama", "genreIncluded=Drama"), + Pair("Ecchi", "genreIncluded=Ecchi"), + Pair("Fantasy", "genreIncluded=Fantasy"), + Pair("Harem", "genreIncluded=Harem"), + Pair("Historical", "genreIncluded=Historical"), + Pair("Horror", "genreIncluded=Horror"), + Pair("Magic", "genreIncluded=Magic"), + Pair("Martial Arts", "genreIncluded=Martial+Arts"), + Pair("Mecha", "genreIncluded=Mecha"), + Pair("Military", "genreIncluded=Military"), + Pair("Music", "genreIncluded=Music"), + Pair("Mystery", "genreIncluded=Mystery"), + Pair("Parody", "genreIncluded=Parody"), + Pair("Police", "genreIncluded=Police"), + Pair("Psychological", "genreIncluded=Psychological"), + Pair("Romance", "genreIncluded=Romance"), + Pair("Samurai", "genreIncluded=Samurai"), + Pair("School", "genreIncluded=School"), + Pair("Sci-Fi", "genreIncluded=Sci-Fi"), + Pair("Seinen", "genreIncluded=Seinen"), + Pair("Shoujo", "genreIncluded=Shoujo"), + Pair("Shoujo Ai", "genreIncluded=Shoujo+Ai"), + Pair("Shounen", "genreIncluded=Shounen"), + Pair("Shounen Ai", "genreIncluded=Shounen+Ai"), + Pair("Slice of Life", "genreIncluded=Slice+of+Life"), + Pair("Space", "genreIncluded=Space"), + Pair("Sports", "genreIncluded=Sports"), + Pair("Super Power", "genreIncluded=Super+Power"), + Pair("Supernatural", "genreIncluded=Supernatural"), + Pair("Thriller", "genreIncluded=Thriller"), + Pair("Vampire", "genreIncluded=Vampire"), + Pair("Yaoi", "genreIncluded=Yaoi"), + Pair("Yuri", "genreIncluded=Yuri"), + ), + ) + + private open class CheckBoxFilterList(name: String, pairs: Array>) : + AnimeFilter.Group(name, pairs.map { CheckBoxVal(it.first, false, it.second) }) { + + fun toUriPart(): String { + return state.filter { it.state }.joinToString("&") { it.uriPart } + } + + private class CheckBoxVal(displayName: String, defaultState: Boolean, val uriPart: String) : + CheckBox(displayName, defaultState) + } + + private class SortFilter : UriPartFilter( + "Sort By", + arrayOf( + Pair("Default", ""), + Pair("Newest Addition", "sort=Newest+Addition"), + Pair("Oldest Addition", "sort=Oldest+Addition"), + Pair("Alphabetical", "sort=Alphabetical"), + Pair("Rating", "sort=Rating"), + Pair("Views", "sort=Views"), + ), + ) + + override fun getFilterList(): AnimeFilterList = AnimeFilterList( + AnimeFilter.Header("Text search ignores filters"), + GenreFilter(), + SortFilter(), + StatusFilter(), + YearFilter(), + LanguageFilter(), + ) + + override fun searchAnimeSelector(): String { + return "div.cards-grid a.anime-meta" + } + + override fun videoFromElement(element: Element): Video { + throw UnsupportedOperationException() + } + + override fun searchAnimeFromElement(element: Element): SAnime { + val anime = SAnime.create() + anime.title = element.attr("title").ifBlank { element.select("span.anime-name").text() } + anime.setUrlWithoutDomain(element.attr("href")) + anime.thumbnail_url = (element.selectFirst("img.lozad")?.attr("data-src")) + return anime + } + + override fun searchAnimeNextPageSelector(): String = "ul.pagination li.next a" + + // =========================== Anime Details ============================ + override fun animeDetailsParse(document: Document): SAnime { + val anime = SAnime.create() + anime.title = document.selectFirst("div.content h1")?.text().orEmpty() + anime.thumbnail_url = document.selectFirst("img.cover")?.attr("src") + + val metaDetails = document.select("div.anime-metadetails div.data") + metaDetails.forEach { data -> + val title = data.selectFirst("div.ap--data-title")?.text() + val description = data.selectFirst("span.description")?.text() + + when (title) { + "Romaji" -> anime.title = description.orEmpty() + "Studios" -> anime.author = description.orEmpty() + "Status" -> anime.status = parseStatus(description.orEmpty()) + "Genres" -> anime.genre = description.orEmpty() + } + } + + anime.description = document.select("p.description").text() + + return anime + } + + private fun parseStatus(status: String): Int { + return when (status.lowercase()) { + "finished airing" -> SAnime.COMPLETED + "currently airing" -> SAnime.ONGOING + else -> SAnime.UNKNOWN + } + } + + // ============================== Episodes ============================== + override fun episodeListSelector(): String = "ul.ep-grid li.ep-card" + + private fun episodeListRequest(anime: SAnime, page: Int): Request { + val url = "$baseUrl${anime.url}watch/?page=$page" + return GET(url, headers) + } + + override fun episodeFromElement(element: Element): SEpisode { + val episode = SEpisode.create() + val title = element.select("a.ep-title").text() + val link = fixUrl(element.select("a.ep-title").attr("href")) + val dateElement = element.selectFirst("time[datetime]") + val releaseDate = dateElement?.attr("datetime") ?: "" + + val date = try { + SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(releaseDate) + } catch (e: Exception) { + null + } + + val episodeNumber = title.substringBefore(":").filter { it.isDigit() }.toIntOrNull() + + episode.setUrlWithoutDomain(link) + episode.name = title + episode.episode_number = episodeNumber?.toFloat() ?: 0F + episode.date_upload = date?.time ?: 0 + + return episode + } + + override fun episodeListParse(response: Response): List { + val anime = SAnime.create() + anime.url = response.request.url.encodedPath + return fetchAllEpisodes(anime) + } + + private fun fixUrl(url: String?): String { + return when { + url == null -> "" + url.startsWith("http") -> url + url.startsWith("//") -> "https:$url" + url.startsWith("/") -> "$baseUrl$url" + else -> "$baseUrl/$url" + } + } + + private fun fetchAllEpisodes(anime: SAnime, page: Int = 1, episodes: MutableList = mutableListOf()): List { + val response = client.newCall(episodeListRequest(anime, page)).execute() + val document = response.asJsoup() + val newEpisodes = document.select(episodeListSelector()).map { element -> episodeFromElement(element) } + episodes.addAll(newEpisodes) + + val hasNextPage = document.select("ul.pagination li a:contains(Next)").isNotEmpty() + return if (hasNextPage) { + fetchAllEpisodes(anime, page + 1, episodes) + } else { + episodes.sortedByDescending { it.episode_number } + } + } + + // ============================ Video Links ============================= + override fun videoListParse(response: Response): List