diff --git a/src/all/netfilm/AndroidManifest.xml b/src/all/netfilm/AndroidManifest.xml
new file mode 100644
index 000000000..acb4de356
--- /dev/null
+++ b/src/all/netfilm/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/src/all/netfilm/build.gradle b/src/all/netfilm/build.gradle
new file mode 100644
index 000000000..bb30ab7db
--- /dev/null
+++ b/src/all/netfilm/build.gradle
@@ -0,0 +1,13 @@
+apply plugin: 'com.android.application'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlinx-serialization'
+
+ext {
+ extName = 'NetFilm'
+ pkgNameSuffix = 'all.netfilm'
+ extClass = '.NetFilm'
+ extVersionCode = 1
+ libVersion = '13'
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/netfilm/res/mipmap-hdpi/ic_launcher.png b/src/all/netfilm/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..a067097ec
Binary files /dev/null and b/src/all/netfilm/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/netfilm/res/mipmap-mdpi/ic_launcher.png b/src/all/netfilm/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..4cd35acc3
Binary files /dev/null and b/src/all/netfilm/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/netfilm/res/mipmap-xhdpi/ic_launcher.png b/src/all/netfilm/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..d4c3bf663
Binary files /dev/null and b/src/all/netfilm/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/netfilm/res/mipmap-xxhdpi/ic_launcher.png b/src/all/netfilm/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..c9475ad6d
Binary files /dev/null and b/src/all/netfilm/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/netfilm/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/netfilm/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..95c63c38f
Binary files /dev/null and b/src/all/netfilm/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/netfilm/res/web_hi_res_512.png b/src/all/netfilm/res/web_hi_res_512.png
new file mode 100644
index 000000000..436a59fcf
Binary files /dev/null and b/src/all/netfilm/res/web_hi_res_512.png differ
diff --git a/src/all/netfilm/src/eu/kanade/tachiyomi/animeextension/all/netfilm/DataModel.kt b/src/all/netfilm/src/eu/kanade/tachiyomi/animeextension/all/netfilm/DataModel.kt
new file mode 100644
index 000000000..22c088698
--- /dev/null
+++ b/src/all/netfilm/src/eu/kanade/tachiyomi/animeextension/all/netfilm/DataModel.kt
@@ -0,0 +1,84 @@
+package eu.kanade.tachiyomi.animeextension.all.netfilm
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class CategoryResponse(
+ val data: List
+) {
+ @Serializable
+ data class CategoryData(
+ val coverVerticalUrl: String,
+ val domainType: Int,
+ val id: String,
+ val name: String,
+ val sort: String
+ )
+}
+
+@Serializable
+data class AnimeInfoResponse(
+ val data: InfoData
+) {
+ @Serializable
+ data class InfoData(
+ val coverVerticalUrl: String,
+ val episodeVo: List,
+ val id: String,
+ val introduction: String,
+ val name: String,
+ val category: Int,
+ val tagList: List,
+ ) {
+ @Serializable
+ data class EpisodeInfo(
+ val id: Int,
+ val seriesNo: Float
+ )
+
+ @Serializable
+ data class IdInfo(
+ val name: String
+ )
+ }
+}
+
+@Serializable
+data class SearchResponse(
+ val data: InfoData
+) {
+ @Serializable
+ data class InfoData(
+ val results: List
+ )
+}
+
+@Serializable
+data class EpisodeResponse(
+ val data: EpisodeData
+) {
+ @Serializable
+ data class EpisodeData(
+ val qualities: List,
+ val subtitles: List
+ ) {
+ @Serializable
+ data class Quality(
+ val quality: Int,
+ val url: String
+ )
+
+ @Serializable
+ data class Subtitle(
+ val language: String,
+ val url: String
+ )
+ }
+}
+
+@Serializable
+data class LinkData(
+ val category: String,
+ val id: String,
+ val episodeId: String? = null
+)
diff --git a/src/all/netfilm/src/eu/kanade/tachiyomi/animeextension/all/netfilm/NetFilm.kt b/src/all/netfilm/src/eu/kanade/tachiyomi/animeextension/all/netfilm/NetFilm.kt
new file mode 100644
index 000000000..9800740cf
--- /dev/null
+++ b/src/all/netfilm/src/eu/kanade/tachiyomi/animeextension/all/netfilm/NetFilm.kt
@@ -0,0 +1,250 @@
+package eu.kanade.tachiyomi.animeextension.all.netfilm
+
+import android.app.Application
+import android.content.SharedPreferences
+import androidx.preference.ListPreference
+import androidx.preference.PreferenceScreen
+import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
+import eu.kanade.tachiyomi.animesource.model.AnimeFilter
+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.Track
+import eu.kanade.tachiyomi.animesource.model.Video
+import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
+import eu.kanade.tachiyomi.network.GET
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import rx.Observable
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import uy.kohesive.injekt.injectLazy
+import kotlin.math.ceil
+import kotlin.math.floor
+
+class NetFilm : ConfigurableAnimeSource, AnimeHttpSource() {
+
+ override val name = "NetFilm"
+
+ override val baseUrl = "https://net-film.vercel.app/api"
+
+ override val lang = "all"
+
+ private var sort = ""
+
+ override val supportsLatest = true
+
+ private val json: Json by injectLazy()
+
+ private val preferences: SharedPreferences by lazy {
+ Injekt.get().getSharedPreferences("source_$id", 0x0000)
+ }
+
+ override val client: OkHttpClient = network.cloudflareClient
+
+ // ============================== Popular ===============================
+
+ override fun popularAnimeRequest(page: Int): Request {
+ if (page == 1) sort = ""
+ return GET("$baseUrl/category?area=&category=1&order=count¶ms=COMIC&size=30&sort=$sort&subtitles=&year=")
+ }
+
+ override fun popularAnimeParse(response: Response): AnimesPage {
+ val parsed = json.decodeFromString(response.body!!.string())
+ if (parsed.data.isEmpty()) {
+ return AnimesPage(emptyList(), false)
+ }
+
+ val animeList = parsed.data.map { ani ->
+ SAnime.create().apply {
+ title = ani.name
+ thumbnail_url = ani.coverVerticalUrl
+ setUrlWithoutDomain(
+ LinkData(
+ ani.domainType.toString(),
+ ani.id
+ ).toJsonString()
+ )
+ }
+ }
+
+ sort = parsed.data.last().sort
+
+ return AnimesPage(animeList, animeList.size == 30)
+ }
+
+ // =============================== Latest ===============================
+
+ override fun latestUpdatesRequest(page: Int): Request {
+ if (page == 1) sort = ""
+ return GET("$baseUrl/category?area=&category=1&order=up¶ms=COMIC&size=30&sort=$sort&subtitles=&year=")
+ }
+
+ override fun latestUpdatesParse(response: Response): AnimesPage = popularAnimeParse(response)
+
+ // =============================== Search ===============================
+
+ override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
+ if (page == 1) sort = ""
+ return if (query.isNotEmpty()) {
+ GET("$baseUrl/search?keyword=$query&size=30")
+ } else {
+ val pageList = filters.find { it is SubPageFilter } as SubPageFilter
+ GET("$baseUrl${pageList.toUriPart()}&sort=$sort&subtitles=&year=")
+ }
+ }
+
+ override fun searchAnimeParse(response: Response): AnimesPage {
+ val url = response.request.url.encodedPath
+ return if (url.startsWith("/api/category")) {
+ popularAnimeParse(response)
+ } else {
+ val parsed = json.decodeFromString(response.body!!.string())
+ if (parsed.data.results.isEmpty()) {
+ return AnimesPage(emptyList(), false)
+ }
+
+ val animeList = parsed.data.results.map { ani ->
+ SAnime.create().apply {
+ title = ani.name
+ thumbnail_url = ani.coverVerticalUrl
+ setUrlWithoutDomain(
+ LinkData(
+ ani.domainType.toString(),
+ ani.id
+ ).toJsonString()
+ )
+ }
+ }
+
+ sort = parsed.data.results.last().sort
+
+ AnimesPage(animeList, animeList.size == 30)
+ }
+ }
+
+ // ============================== FILTERS ===============================
+
+ override fun getFilterList(): AnimeFilterList = AnimeFilterList(
+ AnimeFilter.Header("Text search ignores filters"),
+ SubPageFilter(),
+ )
+
+ private class SubPageFilter : UriPartFilter(
+ "Sub Page",
+ arrayOf(
+ Pair("Popular Movie", "/category?area=&category=1&order=count¶ms=MOVIE,TVSPECIAL&size=30"),
+ Pair("Recent Movie", "/category?area=&category=1&order=up¶ms=MOVIE,TVSPECIAL&size=30"),
+ Pair("Popular TV Series", "/category?area=&category=1&order=count¶ms=TV,SETI,MINISERIES,VARIETY,TALK,DOCUMENTARY&size=30"),
+ Pair("Recent TV Series", "/category?area=&category=1&order=up¶ms=TV,SETI,MINISERIES,VARIETY,TALK,DOCUMENTARY&size=30"),
+ Pair("Popular Anime", "/category?area=&category=1&order=count¶ms=COMIC&size=30"),
+ Pair("Recent Anime", "/category?area=&category=1&order=up¶ms=COMIC&size=30"),
+ )
+ )
+
+ private open class UriPartFilter(displayName: String, val vals: Array>) :
+ AnimeFilter.Select(displayName, vals.map { it.first }.toTypedArray()) {
+ fun toUriPart() = vals[state].second
+ }
+
+ // =========================== Anime Details ============================
+
+ override fun fetchAnimeDetails(anime: SAnime): Observable {
+ val parsed = json.decodeFromString(anime.url)
+ val resp = client.newCall(GET("$baseUrl/detail?category=${parsed.category}&id=${parsed.id}")).execute()
+ val data = json.decodeFromString(resp.body!!.string()).data
+ return Observable.just(
+ anime.apply {
+ title = data.name
+ thumbnail_url = data.coverVerticalUrl
+ description = data.introduction
+ genre = data.tagList.joinToString(", ") { it.name }
+ }
+ )
+ }
+
+ override fun animeDetailsParse(response: Response): SAnime = throw Exception("Not used")
+
+ // ============================== Episodes ==============================
+
+ override fun fetchEpisodeList(anime: SAnime): Observable> {
+ val parsed = json.decodeFromString(anime.url)
+ val resp = client.newCall(GET("$baseUrl/detail?category=${parsed.category}&id=${parsed.id}")).execute()
+ val data = json.decodeFromString(resp.body!!.string()).data
+ val episodeList = data.episodeVo.map { ep ->
+ val formattedEpNum = if (floor(ep.seriesNo) == ceil(ep.seriesNo)) {
+ ep.seriesNo.toInt()
+ } else {
+ ep.seriesNo
+ }
+ SEpisode.create().apply {
+ episode_number = ep.seriesNo
+ setUrlWithoutDomain(
+ LinkData(
+ data.category.toString(),
+ data.id,
+ ep.id.toString()
+ ).toJsonString()
+ )
+ name = "Episode $formattedEpNum"
+ }
+ }
+ return Observable.just(episodeList.reversed())
+ }
+
+ override fun episodeListParse(response: Response): List = throw Exception("Not used")
+
+ // ============================ Video Links =============================
+
+ override fun fetchVideoList(episode: SEpisode): Observable> {
+ val parsed = json.decodeFromString(episode.url)
+ val resp = client.newCall(GET("$baseUrl/episode?category=${parsed.category}&id=${parsed.id}&episode=${parsed.episodeId!!}")).execute()
+ val episodeParsed = json.decodeFromString(resp.body!!.string())
+ val subtitleList = episodeParsed.data.subtitles.map { sub ->
+ Track(sub.url, sub.language)
+ }
+ val videoList = episodeParsed.data.qualities.map { quality ->
+ Video(quality.url, "${quality.quality}p", quality.url, subtitleTracks = subtitleList)
+ }
+ return Observable.just(videoList.sort())
+ }
+
+ // ============================= Utilities ==============================
+
+ private fun LinkData.toJsonString(): String {
+ return json.encodeToString(this)
+ }
+
+ override fun List