diff --git a/src/de/fireanime/AndroidManifest.xml b/src/de/fireanime/AndroidManifest.xml new file mode 100644 index 000000000..acb4de356 --- /dev/null +++ b/src/de/fireanime/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/de/fireanime/build.gradle b/src/de/fireanime/build.gradle new file mode 100644 index 000000000..f06afd22f --- /dev/null +++ b/src/de/fireanime/build.gradle @@ -0,0 +1,17 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'FireAnime' + pkgNameSuffix = 'de.fireanime' + extClass = '.FireAnime' + extVersionCode = 1 + libVersion = '12' + containsNsfw = true +} +dependencies { + implementation project(':lib-ratelimit') +} + +apply from: "$rootDir/common.gradle" diff --git a/src/de/fireanime/res/mipmap-hdpi/ic_launcher.png b/src/de/fireanime/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..93a6dac60 Binary files /dev/null and b/src/de/fireanime/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/de/fireanime/res/mipmap-mdpi/ic_launcher.png b/src/de/fireanime/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..b397b58ef Binary files /dev/null and b/src/de/fireanime/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/de/fireanime/res/mipmap-xhdpi/ic_launcher.png b/src/de/fireanime/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..210856528 Binary files /dev/null and b/src/de/fireanime/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/de/fireanime/res/mipmap-xxhdpi/ic_launcher.png b/src/de/fireanime/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..b2afec9a9 Binary files /dev/null and b/src/de/fireanime/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/de/fireanime/src/eu/kanade/tachiyomi/animeextension/de/fireanime/FAConstants.kt b/src/de/fireanime/src/eu/kanade/tachiyomi/animeextension/de/fireanime/FAConstants.kt new file mode 100644 index 000000000..38ee05081 --- /dev/null +++ b/src/de/fireanime/src/eu/kanade/tachiyomi/animeextension/de/fireanime/FAConstants.kt @@ -0,0 +1,21 @@ +package eu.kanade.tachiyomi.animeextension.de.fireanime + +object FAConstants { + const val LANG_SUB = "Sub" + const val LANG_DUB = "Dub" + + val LANGS = arrayOf(LANG_SUB, LANG_DUB) + + const val PREFERRED_SOURCE = "preferred_source" + const val PREFERRED_LANG = "preferred_sub" + const val SOURCE_SELECTION = "source_selection" + + const val NAME_FIRECDN = "FireCDN" + const val NAME_DOOD = "Doodstream" + + const val URL_FIRECDN = "https://firecdn" + const val URL_DOOD = "https://dood" + + val SOURCE_NAMES = arrayOf(NAME_FIRECDN, NAME_DOOD) + val SOURCE_URLS = arrayOf(URL_FIRECDN, URL_DOOD) +} diff --git a/src/de/fireanime/src/eu/kanade/tachiyomi/animeextension/de/fireanime/FireAnime.kt b/src/de/fireanime/src/eu/kanade/tachiyomi/animeextension/de/fireanime/FireAnime.kt new file mode 100644 index 000000000..2096b4488 --- /dev/null +++ b/src/de/fireanime/src/eu/kanade/tachiyomi/animeextension/de/fireanime/FireAnime.kt @@ -0,0 +1,309 @@ +package eu.kanade.tachiyomi.animeextension.de.fireanime + +import android.app.Application +import android.content.SharedPreferences +import androidx.preference.ListPreference +import androidx.preference.MultiSelectListPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.animeextension.de.fireanime.dto.AbsSourceBaseDto +import eu.kanade.tachiyomi.animeextension.de.fireanime.dto.AnimeBaseDto +import eu.kanade.tachiyomi.animeextension.de.fireanime.dto.AnimeDetailsDto +import eu.kanade.tachiyomi.animeextension.de.fireanime.dto.AnimeDetailsWrapperDto +import eu.kanade.tachiyomi.animeextension.de.fireanime.dto.CdnSourceDto +import eu.kanade.tachiyomi.animeextension.de.fireanime.dto.EpisodeListingWrapperDto +import eu.kanade.tachiyomi.animeextension.de.fireanime.dto.EpisodeSourcesDto +import eu.kanade.tachiyomi.animeextension.de.fireanime.dto.HosterSourceDto +import eu.kanade.tachiyomi.animeextension.de.fireanime.dto.VideoLinkDto +import eu.kanade.tachiyomi.animeextension.de.fireanime.extractors.DoodExtractor +import eu.kanade.tachiyomi.animeextension.de.fireanime.extractors.FireCdnExtractor +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.AnimeHttpSource +import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservableSuccess +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.concurrent.TimeUnit + +class FireAnime : ConfigurableAnimeSource, AnimeHttpSource() { + + override val name = "FireAnime" + + override val baseUrl = "https://api.fireani.me" + + override val lang = "de" + + override val supportsLatest = true + + override val client: OkHttpClient = network.cloudflareClient.newBuilder() + .addInterceptor(RateLimitInterceptor(120, 1, TimeUnit.MINUTES)) + .build() + + private val json = Json { + isLenient = true + ignoreUnknownKeys = true + } + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + // ===== POPULAR ANIME ===== + override fun popularAnimeRequest(page: Int): Request = POST( + "$baseUrl/api/public/airing", + body = FormBody.Builder() + .add("langs[0]", "de-DE") + .build() + ) + + override fun popularAnimeParse(response: Response): AnimesPage = parseAnimeListJson(response, true) + + // ===== LATEST ANIME ===== + override fun latestUpdatesRequest(page: Int): Request = POST( + "$baseUrl/api/public/new", + body = FormBody.Builder() + .add("langs[0]", "de-DE") + .add("limit", "30") + .add("offset", (page - 1).toString()) + .build() + ) + + override fun latestUpdatesParse(response: Response): AnimesPage = parseAnimeListJson(response) + + // ===== ANIME SEARCH ===== + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request = POST( + "$baseUrl/api/public/search", + body = FormBody.Builder() + .add("q", query) + .build() + ) + + override fun searchAnimeParse(response: Response): AnimesPage = parseAnimeListJson(response, true) + + // ===== ANIME LIST PARSING ===== + private fun parseAnimeListJson(response: Response, singlePage: Boolean = false): AnimesPage { + val animes = json.decodeFromString(ListSerializer(AnimeBaseDto.serializer()), response.body!!.string()) + .distinctBy { it.url } + return AnimesPage(animes.map { createAnime(it) }, animes.count() > 0 && !singlePage) + } + + // ===== ANIME DETAILS ===== + override fun animeDetailsRequest(anime: SAnime): Request = POST( + "$baseUrl/api/public/anime", + body = FormBody.Builder() + .add("url", anime.url) + .build() + ) + + override fun fetchAnimeDetails(anime: SAnime): Observable { + return client.newCall(animeDetailsRequest(anime)) + .asObservableSuccess() + .map { response -> + animeDetailsParse(response, anime).apply { initialized = true } + } + } + + override fun animeDetailsParse(response: Response): SAnime = throw UnsupportedOperationException("Not used") + + private fun animeDetailsParse(response: Response, baseAnime: SAnime): SAnime { + val anime = json.decodeFromString(AnimeDetailsWrapperDto.serializer(), response.body!!.string()).response + return createAnime(baseAnime, anime) + } + + // ===== CREATE ANIME ===== + private fun createAnime(anime: AnimeBaseDto): SAnime { + return SAnime.create().apply { + title = anime.title + url = anime.url + thumbnail_url = "$baseUrl/api/get/img/" + anime.imgPoster + "-normal-poster.webp" + status = SAnime.UNKNOWN + } + } + + private fun createAnime(baseAnime: SAnime, details: AnimeDetailsDto): SAnime { + return baseAnime.apply { + description = details.description + genre = "FSK ${details.fsk}, " + (if (details.votingDouble != null) "%.1f/5 ⭐, ".format(details.votingDouble) else "") + details.genres.joinToString(", ") { it.genre } + } + } + + // ===== EPISODE ===== + override fun episodeListRequest(anime: SAnime): Request = POST( + "$baseUrl/api/public/episodes", + body = FormBody.Builder() + .add("url", anime.url) + .build() + ) + + override fun fetchEpisodeList(anime: SAnime): Observable> { + return if (anime.status != SAnime.LICENSED) { + client.newCall(episodeListRequest(anime)) + .asObservableSuccess() + .map { response -> + episodeListParse(response, anime.url) + } + } else { + Observable.error(Exception("Licensed - No episodes to show")) + } + } + + override fun episodeListParse(response: Response): List = throw UnsupportedOperationException("Not used") + + private fun episodeListParse(response: Response, animeUrl: String): List { + val episodes = json.decodeFromString(EpisodeListingWrapperDto.serializer(), response.body!!.string()).response + return episodes.mapIndexed { i, ep -> + SEpisode.create().apply { + episode_number = ep.episode.toFloat() + name = if (ep.title.startsWith("Episode")) ep.title else "Episode ${i + 1}: ${ep.title}" + url = animeUrl + (-1..i).joinToString("") { " " } // Add some spaces so that all episodes are shown + date_upload = System.currentTimeMillis() + } + }.reversed() + } + + // ===== VIDEO SOURCES ===== + override fun videoListRequest(episode: SEpisode): Request = POST( + "$baseUrl/api/public/episode", + body = FormBody.Builder() + .add("url", episode.url.trim()) + .add("ep", "%.0f".format(episode.episode_number)) + .build() + ) + + override fun videoListParse(response: Response): List