diff --git a/src/all/netflixmirror/AndroidManifest.xml b/src/all/netflixmirror/AndroidManifest.xml new file mode 100644 index 000000000..8072ee00d --- /dev/null +++ b/src/all/netflixmirror/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/all/netflixmirror/build.gradle b/src/all/netflixmirror/build.gradle new file mode 100644 index 000000000..21044713a --- /dev/null +++ b/src/all/netflixmirror/build.gradle @@ -0,0 +1,18 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) +} + +ext { + extName = 'NetFlix Mirror' + pkgNameSuffix = 'all.netflixmirror' + extClass = '.NetFlixMirror' + extVersionCode = 1 +} + +dependencies { + implementation(project(':lib-playlist-utils')) +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/netflixmirror/res/mipmap-hdpi/ic_launcher.png b/src/all/netflixmirror/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..68e5ac658 Binary files /dev/null and b/src/all/netflixmirror/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/netflixmirror/res/mipmap-mdpi/ic_launcher.png b/src/all/netflixmirror/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..b5e1811ca Binary files /dev/null and b/src/all/netflixmirror/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/netflixmirror/res/mipmap-xhdpi/ic_launcher.png b/src/all/netflixmirror/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..838d63e3a Binary files /dev/null and b/src/all/netflixmirror/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/netflixmirror/res/mipmap-xxhdpi/ic_launcher.png b/src/all/netflixmirror/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..f9599bf70 Binary files /dev/null and b/src/all/netflixmirror/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/netflixmirror/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/netflixmirror/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..ca41fc42a Binary files /dev/null and b/src/all/netflixmirror/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/netflixmirror/res/web_hi_res_512.png b/src/all/netflixmirror/res/web_hi_res_512.png new file mode 100644 index 000000000..fd4971093 Binary files /dev/null and b/src/all/netflixmirror/res/web_hi_res_512.png differ diff --git a/src/all/netflixmirror/src/eu/kanade/tachiyomi/animeextension/all/netflixmirror/CookieInterceptor.kt b/src/all/netflixmirror/src/eu/kanade/tachiyomi/animeextension/all/netflixmirror/CookieInterceptor.kt new file mode 100644 index 000000000..01fd66e7e --- /dev/null +++ b/src/all/netflixmirror/src/eu/kanade/tachiyomi/animeextension/all/netflixmirror/CookieInterceptor.kt @@ -0,0 +1,45 @@ +package eu.kanade.tachiyomi.animeextension.all.netflixmirror + +import android.util.Log +import android.webkit.CookieManager +import okhttp3.Interceptor +import okhttp3.Response + +class CookieInterceptor( + private val domain: String, + private val key: String, + private val value: String, +) : Interceptor { + + init { + val url = "https://$domain/" + val cookie = "$key=$value; Domain=$domain; Path=/" + setCookie(url, cookie) + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + if (!request.url.host.endsWith(domain)) return chain.proceed(request) + + val cookie = "$key=$value" + val cookieList = request.header("Cookie")?.split("; ") ?: emptyList() + if (cookie in cookieList) return chain.proceed(request) + + setCookie("https://$domain/", "$cookie; Domain=$domain; Path=/") + val prefix = "$key=" + val newCookie = buildList(cookieList.size + 1) { + cookieList.filterNotTo(this) { it.startsWith(prefix) } + add(cookie) + }.joinToString("; ") + val newRequest = request.newBuilder().header("Cookie", newCookie).build() + return chain.proceed(newRequest) + } + + private fun setCookie(url: String, value: String) { + try { + CookieManager.getInstance().setCookie(url, value) + } catch (e: Exception) { + Log.e(domain, "failed to set cookie", e) + } + } +} diff --git a/src/all/netflixmirror/src/eu/kanade/tachiyomi/animeextension/all/netflixmirror/NetFlixMirror.kt b/src/all/netflixmirror/src/eu/kanade/tachiyomi/animeextension/all/netflixmirror/NetFlixMirror.kt new file mode 100644 index 000000000..fb5ad0e36 --- /dev/null +++ b/src/all/netflixmirror/src/eu/kanade/tachiyomi/animeextension/all/netflixmirror/NetFlixMirror.kt @@ -0,0 +1,271 @@ +package eu.kanade.tachiyomi.animeextension.all.netflixmirror + +import android.app.Application +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.animeextension.all.netflixmirror.dto.DetailsDto +import eu.kanade.tachiyomi.animeextension.all.netflixmirror.dto.EpisodeUrl +import eu.kanade.tachiyomi.animeextension.all.netflixmirror.dto.EpisodesDto +import eu.kanade.tachiyomi.animeextension.all.netflixmirror.dto.SearchDto +import eu.kanade.tachiyomi.animeextension.all.netflixmirror.dto.SeasonEpisodesDto +import eu.kanade.tachiyomi.animeextension.all.netflixmirror.dto.VideoList +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.playlistutils.PlaylistUtils +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Request +import okhttp3.Response +import org.jsoup.select.Elements +import rx.Observable +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import kotlin.math.min + +class NetFlixMirror : AnimeHttpSource(), ConfigurableAnimeSource { + + override val name = "NetFlix Mirror" + + override val baseUrl = "https://m.netflixmirror.com" + + override val lang = "all" + + override val supportsLatest = false + + private val json: Json by injectLazy() + + override val client = network.cloudflareClient.newBuilder() + .addNetworkInterceptor( + CookieInterceptor(baseUrl.toHttpUrl().host, "hd", "on"), + ) + .build() + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + + private val xhrHeaders by lazy { + headersBuilder() + .add("X-Requested-With", "XMLHttpRequest") + .build() + } + + private val preferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + private val playListUtils by lazy { + PlaylistUtils(client, headers) + } + + private lateinit var pageElements: Elements + + override fun fetchPopularAnime(page: Int): Observable { + return if (page == 1) { + super.fetchPopularAnime(page) + } else { + Observable.just(paginatedAnimePageParse(page)) + } + } + + override fun popularAnimeRequest(page: Int): Request { + return GET("$baseUrl/home", headers) + } + + override fun popularAnimeParse(response: Response): AnimesPage { + pageElements = response.asJsoup().select("article > a.post-data") + + return paginatedAnimePageParse(1) + } + + private fun paginatedAnimePageParse(page: Int): AnimesPage { + val end = min(page * 20, pageElements.size) + val entries = pageElements.subList((page - 1) * 20, end).map { + SAnime.create().apply { + title = "" // no title here + url = it.attr("data-post") + thumbnail_url = it.selectFirst("img")?.attr("abs:data-src") + } + } + + return AnimesPage(entries, end < pageElements.size) + } + + override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable { + return if (query.isNotEmpty()) { + super.fetchSearchAnime(page, query, filters) + } else { + if (page == 1) { + val pageFilter = filters.filterIsInstance().firstOrNull()?.selected ?: "/home" + val request = GET(baseUrl + pageFilter, headers) + + client.newCall(request) + .asObservableSuccess() + .map(::popularAnimeParse) + } else { + Observable.just(paginatedAnimePageParse(page)) + } + } + } + + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { + val url = "$baseUrl/search.php".toHttpUrl().newBuilder().apply { + addQueryParameter("s", query.trim()) + addQueryParameter("t", System.currentTimeMillis().toString()) + }.build().toString() + + return GET(url, xhrHeaders) + } + + override fun getFilterList() = getFilters() + + override fun searchAnimeParse(response: Response): AnimesPage { + val result = response.parseAs() + + val entries = result.searchResult?.map { + SAnime.create().apply { + url = it.id + title = it.title + thumbnail_url = idToThumbnailUrl(it.id) + } + } ?: emptyList() + + return AnimesPage(entries, false) + } + + override fun animeDetailsRequest(anime: SAnime): Request { + val url = "$baseUrl/post.php?id=${anime.url}&t=${System.currentTimeMillis()}" + + return GET(url, xhrHeaders) + } + + override fun animeDetailsParse(response: Response): SAnime { + val result = response.parseAs() + val id = response.request.url.queryParameter("id")!! + + return SAnime.create().apply { + title = result.title + url = id + thumbnail_url = idToThumbnailUrl(id) + genre = "${result.genre}, ${result.cast}" + author = result.creator + artist = result.director + description = result.desc + if (!result.lang.isNullOrEmpty()) { + description += "\n\nAvailable Language(s): ${result.lang.joinToString { it.language }}" + } + status = result.status + } + } + + override fun episodeListRequest(anime: SAnime) = animeDetailsRequest(anime) + + override fun episodeListParse(response: Response): List { + val result = response.parseAs() + val id = response.request.url.queryParameter("id")!! + + if (result.episodes?.firstOrNull() == null) { + return SEpisode.create().apply { + name = "Movie" + url = EpisodeUrl(id, result.title).let(json::encodeToString) + }.let(::listOf) + } + + val episodes = result.episodes.mapNotNull { + if (it == null) return@mapNotNull null + + it.toSEpisode(result.title) + }.toMutableList() + + result.season?.reversed()?.drop(1)?.forEach { season -> + val seasonRequest = GET("$baseUrl/episodes.php?s=${season.id}&series=$id&t=${System.currentTimeMillis()}", xhrHeaders) + val seasonResponse = client.newCall(seasonRequest).execute().parseAs() + + episodes.addAll( + index = 0, + elements = seasonResponse.episodes?.map { + it.toSEpisode(result.title) + } ?: emptyList(), + ) + } + + return episodes.reversed() + } + + override fun videoListRequest(episode: SEpisode): Request { + val episodeUrl = episode.url.parseAs() + + val url = "$baseUrl/playlist.php".toHttpUrl().newBuilder().apply { + addQueryParameter("id", episodeUrl.id) + addQueryParameter("t", episodeUrl.title) + addQueryParameter("tm", System.currentTimeMillis().toString()) + }.build().toString() + + return GET(url, xhrHeaders) + } + + override fun videoListParse(response: Response): List