Hacktoberfest 2026:维护者为十月标记出来的 issue,仍然开放、适合新手。 浏览 Hacktoberfest issue

owneraj

已关闭
#3,225 0 条评论 1 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 2 天内回复

还没有人认领这个 Issue。

评估

难度
5/5
预计耗时
一周以上
新手友好度
25/100
Issue 类型
功能
描述清晰度
需要澄清
活跃度
活跃
技术栈
android, kotlin
领域
mobile-dev

调研方向

Start by reviewing the proposed NetflixMirrorProvider class, especially getMainPage, search, load, and loadLinks, and compare it with existing provider implementations in the repository. Run the project’s Android build and provider tests to identify integration requirements; done means the provider is accepted, builds successfully, and its search, metadata, episode, playback, and subtitle flows work.

由索引模型根据 Issue 内容生成。

描述

enhancement
Describe your suggested feature

package com.horis.cncverse

import android.content.Context
import com.horis.cncverse.entities.EpisodesData
import com.horis.cncverse.entities.PostData
import com.horis.cncverse.entities.SearchData
import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.utils.*
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import okhttp3.Interceptor
import okhttp3.Response
import org.jsoup.nodes.Element
import com.lagradost.cloudstream3.APIHolder.unixTime
import com.lagradost.api.Log
import java.net.URLEncoder
import com.lagradost.nicehttp.NiceResponse

class NetflixMirrorProvider : MainAPI() {
companion object {
var context: Context? = null
}

override val supportedTypes = setOf(TvType.Movie, TvType.TvSeries)
override var lang = "hi"
override var mainUrl = "https://net52.cc"
private val newUrl = "https://net52.cc"
override var name = "Netflix"
override val hasMainPage = true
private var cookie_value = ""

private val headers = mapOf(
"Accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Accept-Language" to "en-IN,en-US;q=0.9,en;q=0.8",
"Cache-Control" to "max-age=0",
"Connection" to "keep-alive",
"sec-ch-ua" to ""Not(A:Brand";v="8", "Chromium";v="144", "Android WebView";v="144"",
"sec-ch-ua-mobile" to "?0",
"sec-ch-ua-platform" to ""Android"",
"Sec-Fetch-Dest" to "document",
"Sec-Fetch-Mode" to "navigate",
"Sec-Fetch-Site" to "same-origin",
"Sec-Fetch-User" to "?1",
"Upgrade-Insecure-Requests" to "1",
"User-Agent" to "Mozilla/5.0 (Linux; Android 13; Pixel 5 Build/TQ3A.230901.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/144.0.7559.132 Safari/537.36 /OS.Gatu v3.0",
"X-Requested-With" to "XMLHttpRequest"
)

// Native playlist flow URLs
private val playUrl = "https://net77.cc/play.php"
private val playlistBaseUrl = "https://net52.cc/playlist.php"
private val nativeReferer = "https://net77.cc/home"
private val nativeOrigin = "https://net77.cc"

// Cookie store for native flow
private var nativeCookies = mutableMapOf<String, String>()

override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse? {
cookie_value = if (cookie_value.isEmpty()) bypass(newUrl) else cookie_value
val cookies = mapOf("t_hash_t" to cookie_value, "ott" to "nf", "hd" to "on")
val document = app.get(
"$mainUrl/mobile/home?app=1",
cookies = cookies,
headers = headers,
referer = "$mainUrl/mobile/home?app=1"
).document
val items = document.select(".tray-container, #top10").map {
it.toHomePageList()
}
return newHomePageResponse(items, false)
}

private fun Element.toHomePageList(): HomePageList {
val name = select("h2, span").text()
val items = select("article, .top10-post").mapNotNull {
it.toSearchResult()
}
return HomePageList(name, items, isHorizontalImages = false)
}

private fun Element.toSearchResult(): SearchResponse? {
val id = selectFirst("a")?.attr("data-post") ?: attr("data-post")
if (id.isNullOrBlank()) return null
return newAnimeSearchResponse("", Id(id).toJson()) {
posterUrl = "https://imgcdn.kim/poster/v/$id.jpg"
posterHeaders = mapOf("Referer" to "$mainUrl/home")
}
}

override suspend fun search(query: String): List {
cookie_value = if (cookie_value.isEmpty()) bypass(newUrl) else cookie_value
val cookies = mapOf("t_hash_t" to cookie_value, "hd" to "on", "ott" to "nf")
val data = app.get(
"$mainUrl/mobile/search.php?s=$query&t=$unixTime",
referer = "$mainUrl/home",
cookies = cookies
).parsed()
return data.searchResult.map {
newAnimeSearchResponse(it.t, Id(it.id).toJson()) {
posterUrl = "https://imgcdn.kim/poster/v/${it.id}.jpg"
posterHeaders = mapOf("Referer" to "$mainUrl/home")
}
}
}

override suspend fun load(url: String): LoadResponse? {
cookie_value = if (cookie_value.isEmpty()) bypass(newUrl) else cookie_value
val id = parseJson(url).id
val cookies = mapOf("t_hash_t" to cookie_value, "hd" to "on", "ott" to "nf")
val data = app.get(
"$mainUrl/mobile/post.php?id=$id&t=$unixTime",
headers,
referer = "$mainUrl/home",
cookies = cookies
).parsed()

val title = data.title
val episodes = arrayListOf<Episode>()
val isMovie = data.episodes.isEmpty() || data.episodes.first() == null
val tmdbId = data.tmdb_id ?: resolveTmdbId(title, data.year, isMovie)

if (isMovie) {
  episodes.add(newEpisode(LoadData(title, id, tmdbId)) {
    name = title
  })
} else {
  data.episodes.filterNotNull().mapTo(episodes) {
    newEpisode(LoadData(
      title, it.id, tmdbId,
      it.s.replace("S", "").toIntOrNull(),
      it.ep.replace("E", "").toIntOrNull()
    )) {
      this.name = it.t
      this.episode = it.ep.replace("E", "").toIntOrNull()
      this.season = it.s.replace("S", "").toIntOrNull()
      this.posterUrl = "https://imgcdn.kim/poster/v/150/${it.id}.jpg"
      this.runTime = it.time.replace("m", "").toIntOrNull()
    }
  }
  if (data.nextPageShow == 1) {
    episodes.addAll(getEpisodes(title, url, data.nextPageSeason!!, 2, tmdbId))
  }
  data.season?.dropLast(1)?.amap {
    episodes.addAll(getEpisodes(title, url, it.id, 1, tmdbId))
  }
}

val cast = data.cast?.split(",")?.map {
  it.trim()
}
?.filter {
  it.isNotEmpty()
}?.map {
  ActorData(Actor(it))
}
val genre = data.genre?.split(",")?.map {
  it.trim()
}?.filter {
  it.isNotEmpty()
}
val type = if (isMovie) TvType.Movie else TvType.TvSeries

return newTvSeriesLoadResponse(title, url, type, episodes) {
  posterUrl = "https://imgcdn.kim/poster/v/$id.jpg"
  backgroundPosterUrl = "https://imgcdn.kim/poster/v/$id.jpg"
  posterHeaders = mapOf("Referer" to "$mainUrl/home")
  plot = data.desc
  year = data.year.toIntOrNull()
  tags = genre
  actors = cast
  this.score = Score.from10(data.match?.replace("IMDb ", ""))
  this.duration = convertRuntimeToMinutes(data.runtime.toString())
  this.contentRating = data.ua
}

}

private suspend fun getEpisodes(
title: String, eid: String, sid: String, page: Int, tmdbId: String?
): List {
val episodes = arrayListOf()
val cookies = mapOf("t_hash_t" to cookie_value, "hd" to "on", "ott" to "nf")
var pg = page
while (true) {
val data = app.get(
"$mainUrl/mobile/episodes.php?s=$sid&series=$eid&t=$unixTime&page=$pg",
headers,
referer = "$mainUrl/home",
cookies = cookies
).parsed()
data.episodes?.mapTo(episodes) {
newEpisode(LoadData(
title, it.id, tmdbId,
it.s.replace("S", "").toIntOrNull(),
it.ep.replace("E", "").toIntOrNull()
)) {
name = it.t
episode = it.ep.replace("E", "").toIntOrNull()
season = it.s.replace("S", "").toIntOrNull()
this.posterUrl = "https://imgcdn.kim/poster/v/150/${it.id}.jpg"
this.runTime = it.time.replace("m", "").toIntOrNull()
}
}
if (data.nextPageShow == 0) break
pg++
}
return episodes
}

// Native play.php -> playlist.php flow with subtitles

override suspend fun loadLinks(
data: String,
isCasting: Boolean,
subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit
): Boolean {
val loadData = parseJson(data)
val id = loadData.id

// NewTV API flow (same as HotStar/Disney/Prime providers) — primary.
// No Cloudflare cookie wall: resolves the API base via mobiledetect.*
// domains -> checknewtv.php -> token_hash, then asks for the HLS link.
try {
  NetmirrorThrottler.throttle()
  val apiBase = resolveApiUrl()
  val newTvResp = app.get(
    "$apiBase/newtv/player.php?id=$id",
    headers = buildNewTvHeaders("nf")
  ).parsed<NewTvPlayerResponse>()

  if (!newTvResp.video_link.isNullOrBlank()) {
    Log.i("NetflixMirror", "NewTV flow ok for id=$id")
    callback.invoke(
      newExtractorLink(name, name, newTvResp.video_link, type = ExtractorLinkType.M3U8) {
        this.referer = newTvResp.referer ?: apiBase
      }
    )
    return true
  }
  Log.w("NetflixMirror", "NewTV flow empty for id=$id, falling back")
} catch (e: Exception) {
  Log.w("NetflixMirror", "NewTV flow failed for id=$id: ${e.message}")
}

// Ensure we have fresh cookies for the native flow
ensureNativeCookies(id)

val playResp = try {
  NetmirrorThrottler.throttle()
  app.post(
    playUrl,
    data = mapOf("id" to id),
    headers = mapOf(
      "Accept" to "application/json, text/javascript, */*; q=0.01",
      "Accept-Language" to "en-IN,en-US;q=0.9,en;q=0.8",
      "Content-Type" to "application/x-www-form-urlencoded; charset=UTF-8",
      "Origin" to nativeOrigin,
      "Referer" to nativeReferer,
      "X-Requested-With" to "XMLHttpRequest",
      "User-Agent" to headers["User-Agent"]!!
    ),
    cookies = nativeCookies
  ).parsed<PlayResponse>()
} catch (e: Exception) {
  Log.e("NetflixMirror", "play.php failed for id=$id: ${e.message}")
  // Fallback to old TMDB embed method
  return loadLinksFallback(loadData, subtitleCallback, callback)
}

val h = playResp.h ?: run {
  Log.e("NetflixMirror", "play.php returned no h-token for id=$id")
  return loadLinksFallback(loadData, subtitleCallback, callback)
}

val tm = unixTime
val playlistUrl = buildString {
  append(playlistBaseUrl)
  append("?id=$id")
  append("&t=${URLEncoder.encode(loadData.title, "UTF-8")}")
  append("&tm=$tm")
  append("&h=${URLEncoder.encode(h, "UTF-8")}")
}

val playlist = try {
  NetmirrorThrottler.throttle()
  val playlistText = app.get(
    playlistUrl,
    headers = mapOf(
      "Accept" to "application/json, text/javascript, */*; q=0.01",
      "Accept-Language" to "en-IN,en-US;q=0.9,en;q=0.8",
      "Referer" to nativeReferer,
      "Origin" to nativeOrigin,
      "X-Requested-With" to "XMLHttpRequest",
      "User-Agent" to headers["User-Agent"]!!
    ),
    cookies = nativeCookies
  ).text.trim()
  // Server sometimes wraps the playlist in a JSON array ([{...}]) and
  // sometimes returns the object directly — handle both.
  if (playlistText.startsWith("[")) {
    parseJson<List<PlaylistResponse>>(playlistText).firstOrNull()
  } else {
    parseJson<PlaylistResponse>(playlistText)
  }
} catch (e: Exception) {
  Log.e("NetflixMirror", "playlist.php failed for id=$id: ${e.message}")
  return loadLinksFallback(loadData, subtitleCallback, callback)
}

if (playlist == null) {
  Log.e("NetflixMirror", "playlist.php returned empty for id=$id")
  return loadLinksFallback(loadData, subtitleCallback, callback)
}

var found = false

// Step 3: Register HLS sources with quality
playlist.sources?.forEach {
  source ->
  val streamUrl = if (source.file.startsWith("http")) {
    source.file
  } else {
    "https://net52.cc${source.file}"
  }

  val quality = when {
    source.label.contains("1080", true) -> Qualities.P1080.value
    source.label.contains("720", true) -> Qualities.P720.value
    source.label.contains("480", true) -> Qualities.P480.value
    source.label.contains("360", true) -> Qualities.P360.value
    source.label.contains("Full HD", true) -> Qualities.P1080.value
    source.label.contains("Mid HD", true) -> Qualities.P720.value
    source.label.contains("Low HD", true) -> Qualities.P480.value
    else -> Qualities.Unknown.value
  }

  callback.invoke(
    newExtractorLink(
      name,
      "$name ${source.label}",
      streamUrl,
      type = ExtractorLinkType.M3U8
    ) {
      this.referer = nativeReferer
      this.quality = quality
    }
  )
  found = true
}

// Step 4: Register subtitle tracks
playlist.tracks?.forEach {
  track ->
  if (track.kind.equals("captions", true) || track.kind.equals("subtitles", true)) {
    val subUrl = if (track.file.startsWith("//")) {
      "https:${track.file}"
    } else if (!track.file.startsWith("http")) {
      "https://subscdn.top${track.file}"
    } else {
      track.file
    }

    subtitleCallback.invoke(
      SubtitleFile(track.label, subUrl)
    )
  }
}

// If native flow found nothing, try fallback
if (!found) {
  Log.w("NetflixMirror", "Native flow returned no sources for id=$id, trying fallback")
  return loadLinksFallback(loadData, subtitleCallback, callback)
}

return true

}

// Fallback: TMDB embed API
private val net27Url = "https://net27.cc"
private val net27Referer = "https://videodownloader.site/"
private val net27Headers = mapOf(
"Accept" to "application/json",
"Referer" to net27Referer,
"User-Agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
)

private suspend fun loadLinksFallback(
loadData: LoadData,
subtitleCallback: (SubtitleFile) -> Unit,
callback: (ExtractorLink) -> Unit
): Boolean {
val tmdbId = loadData.tmdbId ?: run {
Log.e("NetflixMirror", "Fallback aborted: no tmdbId for '${loadData.title}'")
return false
}
val isMovie = loadData.season == null

val embedUrl = if (isMovie) {
  "$net27Url/api/embed-tmdb/$tmdbId"
} else {
  "$net27Url/api/embed-tmdb/$tmdbId?type=tv&s=${loadData.season}&e=${loadData.episode ?: 1}"
}

val response = try {
  app.get(embedUrl, headers = net27Headers).parsed<Net27Response>()
} catch (e: Exception) {
  Log.e("NetflixMirror", "embed-tmdb request failed: ${e.message}")
  return false
}

if (response.ok != true) {
  Log.e("NetflixMirror", "embed-tmdb returned not-ok")
  return false
}

var found = false

response.streams?.forEach {
  stream ->
  callback.invoke(
    newExtractorLink(name, "$name ${stream.resolution}p", stream.url, type = ExtractorLinkType.VIDEO) {
      this.referer = net27Referer
      this.quality = stream.resolution
    }
  )
  found = true
}

if (response.streams.isNullOrEmpty() && !response.mp4.isNullOrBlank()) {
  callback.invoke(
    newExtractorLink(name, name, response.mp4, type = ExtractorLinkType.VIDEO) {
      this.referer = net27Referer
      this.quality = response.resolution?.toIntOrNull() ?: Qualities.Unknown.value
    }
  )
  found = true
}

response.captions?.forEach {
  caption ->
  subtitleCallback.invoke(SubtitleFile(caption.name, caption.url))
}

if (!found && response.noSource == true) {
  val message = response.error ?: "This title is still being added. Check back later."
  throw ErrorLoadingException(message)
}

return found

}

// Cookie management for native flow
private suspend fun ensureNativeCookies(contentId: String) {
// If we already have the essential cookies, skip
if (nativeCookies.containsKey("user_token") &&
nativeCookies.containsKey("t_hash_p") &&
nativeCookies.containsKey("cf_clearance")
) {
return
}

// Warm up cookies by visiting the home page
// This should trigger Cloudflare challenge and set initial cookies
try {
  val homeResp = app.get(
    "$nativeOrigin/home",
    headers = mapOf(
      "User-Agent" to headers["User-Agent"]!!,
      "Accept" to "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
      "Accept-Language" to "en-IN,en-US;q=0.9,en;q=0.8"
    )
  )

  // Extract cookies from response
  extractCookiesFromResponse(homeResp)

  // Also try to get the SE cookie by visiting the content page
  val postResp = app.get(
    "$mainUrl/mobile/post.php?id=$contentId&t=$unixTime",
    headers = headers,
    referer = "$mainUrl/home",
    cookies = mapOf("t_hash_t" to cookie_value, "hd" to "on", "ott" to "nf")
  )
  extractCookiesFromResponse(postResp)

} catch (e: Exception) {
  Log.w("NetflixMirror", "Cookie warmup failed: ${e.message}")
}

}

private fun extractCookiesFromResponse(response: NiceResponse) {
response.headers.values("Set-Cookie").forEach { cookieStr ->
val keyValue = cookieStr.split(";").firstOrNull()?.trim() ?: return@forEach
val parts = keyValue.split("=", limit = 2)
if (parts.size == 2) {
nativeCookies[parts[0]] = parts[1]
}
}
}

@Suppress("ObjectLiteralToLambda")
override fun getVideoInterceptor(extractorLink: ExtractorLink): Interceptor? {
return object : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val isNativeHost = request.url.host.contains("net52") ||
request.url.host.contains("net77") ||
request.url.host.contains("net22") ||
request.url.host.contains("net27")
// Use the link's own referer (fallback flow needs videodownloader.site),
// defaulting to the native referer when unset.
val referer = extractorLink.referer?.takeIf { it.isNotBlank() } ?: nativeReferer
val builder = request.newBuilder()
.header("Referer", referer)
if (isNativeHost) {
builder.header("Origin", nativeOrigin)
// The native CDN requires the hotlink/cf cookies collected during warmup.
val cookies = mutableMapOf<String, String>()
if (cookie_value.isNotEmpty()) cookies["t_hash_t"] = cookie_value
cookies.putAll(nativeCookies)
if (cookies.isNotEmpty()) {
builder.header("Cookie", cookies.entries.joinToString("; ") { "${it.key}=${it.value}" })
}
}
return chain.proceed(builder.build())
}
}
}

// Data classes
data class Id(val id: String)

data class LoadData(
val title: String,
val id: String,
val tmdbId: String? = null,
val season: Int? = null,
val episode: Int? = null
)

// Native flow responses
data class PlayResponse(val h: String? = null)

data class PlaylistResponse(
val title: String? = null,
val image2: String? = null,
val sources: List? = null,
val tracks: List? = null
)

data class PlaylistSource(
val file: String,
val label: String,
val type: String,
val default: String? = null
)

data class PlaylistTrack(
val kind: String,
val file: String,
val label: String,
val language: String? = null
)

// Fallback (TMDB embed) responses
data class Net27Response(
val ok: Boolean? = null,
val mp4: String? = null,
val resolution: String? = null,
val streams: List? = null,
val captions: List? = null,
val noSource: Boolean? = null,
val error: String? = null
)

data class Net27Stream(
val url: String,
val resolution: Int
)

data class Net27Caption(
val lang: String,
val name: String,
val url: String
)
}

Other details

No response

Acknowledgements
  • My suggestion is NOT about adding a new provider
  • I have searched the existing issues and this is a new ticket, NOT a duplicate or related to another open issue.
主要语言
Kotlin
星标
11k
派生
1.1k
平均合并
3 天 6 分钟
30 天内合并 PR
17

环境准备

这个项目没有提供开发容器、Dockerfile 或贡献指南,环境需要你自己搭建:先看它的 README,通用步骤见我们的新手贡献指南。

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

recloudstream/cloudstream 的其他 Issue

查看 recloudstream/cloudstream 的全部 Issue

相似的 Issue

更多 Kotlin Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。