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