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