diff --git a/src/pt/otakuanimes/AndroidManifest.xml b/src/pt/otakuanimes/AndroidManifest.xml new file mode 100644 index 00000000..b467aa92 --- /dev/null +++ b/src/pt/otakuanimes/AndroidManifest.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <application> + <activity + android:name=".pt.otakuanimes.OtakuAnimesUrlActivity" + android:excludeFromRecents="true" + android:exported="true" + android:theme="@android:style/Theme.NoDisplay"> + <intent-filter> + <action android:name="android.intent.action.VIEW" /> + + <category android:name="android.intent.category.DEFAULT" /> + <category android:name="android.intent.category.BROWSABLE" /> + + <data + android:host="otakuanimesscc.com" + android:pathPattern="/..*" + android:scheme="https" /> + <data + android:host="otakuanimesscc.com" + android:pathPattern="/anime/..*" + android:scheme="https" /> + </intent-filter> + </activity> + </application> +</manifest> diff --git a/src/pt/otakuanimes/build.gradle b/src/pt/otakuanimes/build.gradle new file mode 100644 index 00000000..cba7bfa4 --- /dev/null +++ b/src/pt/otakuanimes/build.gradle @@ -0,0 +1,12 @@ +ext { + extName = 'OtakuAnimes' + extClass = '.OtakuAnimes' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" + +dependencies { + implementation(project(":lib:playlist-utils")) +} diff --git a/src/pt/otakuanimes/res/mipmap-hdpi/ic_launcher.png b/src/pt/otakuanimes/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..713c126d Binary files /dev/null and b/src/pt/otakuanimes/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/pt/otakuanimes/res/mipmap-mdpi/ic_launcher.png b/src/pt/otakuanimes/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..ce60d541 Binary files /dev/null and b/src/pt/otakuanimes/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/pt/otakuanimes/res/mipmap-xhdpi/ic_launcher.png b/src/pt/otakuanimes/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..c507c31f Binary files /dev/null and b/src/pt/otakuanimes/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/pt/otakuanimes/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/otakuanimes/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..f9d9a728 Binary files /dev/null and b/src/pt/otakuanimes/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/pt/otakuanimes/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/otakuanimes/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..b10fec85 Binary files /dev/null and b/src/pt/otakuanimes/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/pt/otakuanimes/src/eu/kanade/tachiyomi/animeextension/pt/otakuanimes/OtakuAnimes.kt b/src/pt/otakuanimes/src/eu/kanade/tachiyomi/animeextension/pt/otakuanimes/OtakuAnimes.kt new file mode 100644 index 00000000..cd5b6ca7 --- /dev/null +++ b/src/pt/otakuanimes/src/eu/kanade/tachiyomi/animeextension/pt/otakuanimes/OtakuAnimes.kt @@ -0,0 +1,234 @@ +package eu.kanade.tachiyomi.animeextension.pt.otakuanimes + +import android.app.Application +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource +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.model.SEpisode +import eu.kanade.tachiyomi.animesource.model.Video +import eu.kanade.tachiyomi.animesource.online.ParsedAnimeHttpSource +import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.awaitSuccess +import eu.kanade.tachiyomi.util.asJsoup +import eu.kanade.tachiyomi.util.parallelCatchingFlatMapBlocking +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class OtakuAnimes : ConfigurableAnimeSource, ParsedAnimeHttpSource() { + + override val name = "Otaku Animes" + + override val baseUrl = "https://otakuanimesscc.com" + + override val lang = "pt-BR" + + override val supportsLatest = true + + private val preferences by lazy { + Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000) + } + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", baseUrl) + .add("Origin", baseUrl) + + // ============================== Popular =============================== + override fun popularAnimeRequest(page: Int) = GET(baseUrl, headers) + + override fun popularAnimeSelector() = "div.calendarioL div.ultAnisContainerItem > a" + + override fun popularAnimeFromElement(element: Element) = SAnime.create().apply { + setUrlWithoutDomain(element.attr("href")) + title = element.selectFirst("div.aniNome")!!.text().trim() + thumbnail_url = element.selectFirst("img")?.attr("data-lazy-src") + } + + override fun popularAnimeNextPageSelector() = null + + // =============================== Latest =============================== + override fun latestUpdatesRequest(page: Int) = + GET("$baseUrl/lista-de-animes/page/$page", headers) + + override fun latestUpdatesSelector() = "div.ultAnisContainer div.ultAnisContainerItem > a" + + override fun latestUpdatesFromElement(element: Element) = popularAnimeFromElement(element) + + override fun latestUpdatesNextPageSelector() = "div.paginacao a.next" + + // =============================== Search =============================== + override suspend fun getSearchAnime( + page: Int, + query: String, + filters: AnimeFilterList, + ): AnimesPage { + return if (query.startsWith(PREFIX_SEARCH)) { + val path = query.removePrefix(PREFIX_SEARCH) + client.newCall(GET("$baseUrl/$path")) + .awaitSuccess() + .use(::searchAnimeByIdParse) + } else { + super.getSearchAnime(page, query, filters) + } + } + + private fun searchAnimeByIdParse(response: Response): AnimesPage { + val details = animeDetailsParse(response).apply { + setUrlWithoutDomain(response.request.url.toString()) + initialized = true + } + + return AnimesPage(listOf(details), false) + } + + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { + val url = "$baseUrl/page".toHttpUrl().newBuilder() + .addPathSegment(page.toString()) + .addQueryParameter("s", query) + .build() + + return GET(url, headers = headers) + } + + override fun searchAnimeSelector() = "div.SectionBusca div.ultAnisContainerItem > a" + + override fun searchAnimeFromElement(element: Element) = latestUpdatesFromElement(element) + + override fun searchAnimeNextPageSelector() = latestUpdatesNextPageSelector() + + // =========================== Anime Details ============================ + override fun animeDetailsParse(document: Document): SAnime { + val doc = getRealDoc(document) + + return SAnime.create().apply { + setUrlWithoutDomain(doc.location()) + title = doc.selectFirst("div.animeFirstContainer h1")!!.text() + thumbnail_url = doc.selectFirst("div.animeCapa img")?.attr("data-lazy-src") + description = doc.selectFirst("div.animeSecondContainer > p")?.text() + genre = doc.select("ul.animeGen li").eachText()?.joinToString(", ") + } + } + + // ============================== Episodes ============================== + override fun episodeListParse(response: Response): List<SEpisode> { + return getRealDoc(response.asJsoup()) + .select(episodeListSelector()) + .map(::episodeFromElement) + .reversed() + } + + override fun episodeListSelector() = "div.sectionEpiInAnime a" + + override fun episodeFromElement(element: Element) = SEpisode.create().apply { + setUrlWithoutDomain(element.attr("href")) + element.text().let { + name = it.trim() + episode_number = name.substringAfterLast(" ").toFloatOrNull() ?: 1F + } + } + + // ============================ Video Links ============================= + override fun videoListParse(response: Response): List<Video> { + val document = response.asJsoup() + + return document.select("#player iframe") + .parallelCatchingFlatMapBlocking { + getVideosFromURL(it.attr("src")) + } + } + + private val playlistUtils by lazy { PlaylistUtils(client) } + private fun getVideosFromURL(url: String): List<Video> { + return when { + "playerhls" in url -> { + return client.newCall(GET(url, headers)).execute().body.string() + .substringAfter("sources: [") + .substringBefore("],").split("{").drop(1).map { + val label = it.substringAfter("label: \"") + .substringBefore('"') + + val playlistUrl = it.substringAfter("file: '") + .substringBefore("'") + .replace("\\", "") + + return playlistUtils.extractFromHls( + playlistUrl, + videoNameGen = { label }, + ) + } + } + + else -> emptyList() + } + } + + override fun videoListSelector(): String { + throw UnsupportedOperationException() + } + + override fun videoFromElement(element: Element): Video { + throw UnsupportedOperationException() + } + + override fun videoUrlParse(document: Document): String { + throw UnsupportedOperationException() + } + + // ============================== Settings ============================== + override fun setupPreferenceScreen(screen: PreferenceScreen) { + ListPreference(screen.context).apply { + key = PREF_QUALITY_KEY + title = PREF_QUALITY_TITLE + entries = PREF_QUALITY_VALUES + entryValues = PREF_QUALITY_VALUES + setDefaultValue(PREF_QUALITY_DEFAULT) + summary = "%s" + setOnPreferenceChangeListener { _, newValue -> + val selected = newValue as String + val index = findIndexOfValue(selected) + val entry = entryValues[index] as String + preferences.edit().putString(key, entry).commit() + } + }.also(screen::addPreference) + } + + override fun List<Video>.sort(): List<Video> { + val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!! + return sortedWith( + compareBy( + { it.quality.contains(quality) }, + { REGEX_QUALITY.find(it.quality)?.groupValues?.get(1)?.toIntOrNull() ?: 0 }, + ), + ).reversed() + } + + // ============================= Utilities ============================== + private fun getRealDoc(document: Document): Document { + val menu = document.selectFirst("a.aniBack") + if (menu != null) { + val originalUrl = menu.parent()!!.attr("href") + val response = client.newCall(GET(originalUrl, headers)).execute() + return response.asJsoup() + } + + return document + } + + companion object { + const val PREFIX_SEARCH = "path:" + private val REGEX_QUALITY by lazy { Regex("""(\d+)p""") } + + private const val PREF_QUALITY_KEY = "preferred_quality" + private const val PREF_QUALITY_TITLE = "Qualidade preferida" + private const val PREF_QUALITY_DEFAULT = "720p" + private val PREF_QUALITY_VALUES = arrayOf("360p", "720p", "1080p") + } +} diff --git a/src/pt/otakuanimes/src/eu/kanade/tachiyomi/animeextension/pt/otakuanimes/OtakuAnimesUrlActivity.kt b/src/pt/otakuanimes/src/eu/kanade/tachiyomi/animeextension/pt/otakuanimes/OtakuAnimesUrlActivity.kt new file mode 100644 index 00000000..18c7da70 --- /dev/null +++ b/src/pt/otakuanimes/src/eu/kanade/tachiyomi/animeextension/pt/otakuanimes/OtakuAnimesUrlActivity.kt @@ -0,0 +1,46 @@ +package eu.kanade.tachiyomi.animeextension.pt.otakuanimes + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.util.Log +import kotlin.system.exitProcess + +/** + * Springboard that accepts https://otakuanimesscc.com/a/<slug> and https://otakuanimesscc.com/<id> intents + * and redirects them to the main Aniyomi process. + */ +class OtakuAnimesUrlActivity : Activity() { + + private val tag = javaClass.simpleName + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val pathSegments = intent?.data?.pathSegments + if (pathSegments != null && pathSegments.size > 0) { + val searchQuery = if (pathSegments.size > 1) { + "${pathSegments[0]}/${pathSegments[1]}" + } else { + pathSegments[0] + } + + val mainIntent = Intent().apply { + action = "eu.kanade.tachiyomi.ANIMESEARCH" + putExtra("query", "${OtakuAnimes.PREFIX_SEARCH}$searchQuery") + putExtra("filter", packageName) + } + + try { + startActivity(mainIntent) + } catch (e: ActivityNotFoundException) { + Log.e(tag, e.toString()) + } + } else { + Log.e(tag, "could not parse uri from intent $intent") + } + + finish() + exitProcess(0) + } +}