Skip to content

Commit

Permalink
feat(all/netflixmirror): New Source, NetFlix Mirror
Browse files Browse the repository at this point in the history
  • Loading branch information
AwkwardPeak7 committed Sep 21, 2023
1 parent e3782cc commit 94de205
Show file tree
Hide file tree
Showing 15 changed files with 472 additions and 0 deletions.
2 changes: 2 additions & 0 deletions src/all/netflixmirror/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
18 changes: 18 additions & 0 deletions src/all/netflixmirror/build.gradle
Original file line number Diff line number Diff line change
@@ -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"
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/all/netflixmirror/res/web_hi_res_512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Application>().getSharedPreferences("source_$id", 0x0000)
}

private val playListUtils by lazy {
PlaylistUtils(client, headers)
}

private lateinit var pageElements: Elements

override fun fetchPopularAnime(page: Int): Observable<AnimesPage> {
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<AnimesPage> {
return if (query.isNotEmpty()) {
super.fetchSearchAnime(page, query, filters)
} else {
if (page == 1) {
val pageFilter = filters.filterIsInstance<PageFilter>().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<SearchDto>()

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<DetailsDto>()
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<SEpisode> {
val result = response.parseAs<EpisodesDto>()
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<SeasonEpisodesDto>()

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<EpisodeUrl>()

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<Video> {
val result = response.parseAs<VideoList>()

val masterPlayList = result
.firstOrNull()
?.sources
?.firstOrNull()
?.file
?.let { baseUrl + it }
?.toHttpUrlOrNull()
?.newBuilder()
?.removeAllQueryParameters("q")
?.build()
?.toString()
?: return emptyList()

return playListUtils.extractFromHls(masterPlayList)
}

override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY, PREF_QUALITY_DEFAULT)!!

return this.sortedWith(
compareBy { it.quality.contains(quality) },
).reversed()
}

override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_QUALITY
title = PREF_QUALITY_TITLE
entries = arrayOf("720p", "480p", "360p")
entryValues = arrayOf("720", "480", "360")
setDefaultValue(PREF_QUALITY_DEFAULT)
summary = "%s"
}.also(screen::addPreference)
}

private inline fun <reified T> String.parseAs(): T =
json.decodeFromString(this)

private inline fun <reified T> Response.parseAs(): T =
use { it.body.string() }.parseAs()

companion object {
private const val PREF_QUALITY = "preferred_quality"
private const val PREF_QUALITY_TITLE = "Preferred quality"
private const val PREF_QUALITY_DEFAULT = "720"

private fun idToThumbnailUrl(id: String) = "https://img.netflixmirror.com/poster/v/$id.jpg"
}

override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException("Not used")
override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException("Not used")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package eu.kanade.tachiyomi.animeextension.all.netflixmirror

import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList

abstract class SelectFilter(
name: String,
private val options: List<Pair<String, String>>,
) : AnimeFilter.Select<String>(
name,
options.map { it.first }.toTypedArray(),
) {
val selected get() = options[state].second.takeUnless { it.isEmpty() }
}

class PageFilter : SelectFilter(
"Page",
listOf(
Pair("Home", ""),
Pair("Movies", "/movies"),
Pair("Series", "/series"),
),
)

fun getFilters() = AnimeFilterList(
PageFilter(),
AnimeFilter.Separator("Doesn't work with text search."),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package eu.kanade.tachiyomi.animeextension.all.netflixmirror.dto

import eu.kanade.tachiyomi.animesource.model.SAnime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class DetailsDto(
val title: String,
val genre: String,
val cast: String,
val desc: String,
val creator: String,
val director: String,
val episodes: List<Episode?>? = emptyList(),
val lang: List<LanguageDto>? = emptyList(),
) {
val status = if (episodes?.firstOrNull() == null) {
SAnime.COMPLETED
} else {
SAnime.UNKNOWN
}
}

@Serializable
data class LanguageDto(
@SerialName("l") val language: String,
)
Loading

0 comments on commit 94de205

Please sign in to comment.