fix(lib/lycoris&lulu) Repair decode json and work LuluStream (#810)

* fix(lib/lycoris): fix parse json

* fix(lib/lycoris): small changes

* fix(lib/lycoris): small changes v2

* fix(lib/lycoris): small changes v3

* fix(lib/lycoris): small changes v4

* fix(lib/lycoris): small changes v5

* fix(lib/lycoris&lulu): big change v1

* fix(lib/lycoris&lulu): small change v2

* fix(lib/lycoris&lulu): small change v3

* fix(lib/lycoris&lulu&docchi): small change v4

* fix(pl/docchi): tiny change v1

* fix(lib/lulu): tiny change v2
This commit is contained in:
Cezary 2025-04-06 20:41:23 +02:00 committed by GitHub
parent 2574c7fbf8
commit 8d9e763dc4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 124 additions and 109 deletions

View file

@ -3,16 +3,18 @@ package eu.kanade.tachiyomi.lib.luluextractor
import eu.kanade.tachiyomi.animesource.model.Video import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.util.regex.Pattern import java.util.regex.Pattern
class LuluExtractor(private val client: OkHttpClient) { class LuluExtractor(private val client: OkHttpClient, headers: Headers) {
private val headers = Headers.Builder() private val headers = headers.newBuilder()
.add("Referer", "https://luluvdo.com") .add("Referer", "https://luluvdo.com/")
.add("Origin", "https://luluvdo.com") .add("Origin", "https://luluvdo.com")
.build() .build()
//Credit: https://github.com/skoruppa/docchi-stremio-addon/blob/main/app/players/lulustream.py
fun videosFromUrl(url: String, prefix: String): List<Video> { fun videosFromUrl(url: String, prefix: String): List<Video> {
val videos = mutableListOf<Video>() val videos = mutableListOf<Video>()
@ -22,7 +24,7 @@ class LuluExtractor(private val client: OkHttpClient) {
val fixedUrl = fixM3u8Link(m3u8Url) val fixedUrl = fixM3u8Link(m3u8Url)
val quality = getResolution(fixedUrl) val quality = getResolution(fixedUrl)
videos.add(Video(fixedUrl, "${prefix}Lulu - $quality", fixedUrl)) videos.add(Video(fixedUrl, "${prefix}Lulu - $quality", fixedUrl, headers))
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
@ -50,34 +52,41 @@ class LuluExtractor(private val client: OkHttpClient) {
private fun fixM3u8Link(link: String): String { private fun fixM3u8Link(link: String): String {
val paramOrder = listOf("t", "s", "e", "f") val paramOrder = listOf("t", "s", "e", "f")
val baseUrl = link.split("?").first() val params = Pattern.compile("[?&]([^=]*)=([^&]*)").matcher(link).let { matcher ->
val params = link.split("?").getOrNull(1)?.split("&") ?: emptyList() generateSequence { if (matcher.find()) matcher.group(1) to matcher.group(2) else null }.toList()
}
val paramMap = mutableMapOf<String, String>()
val extraParams = mutableMapOf( val paramDict = mutableMapOf<String, String>()
"i" to "0.3", val extraParams = mutableMapOf<String, String>()
"sp" to "0"
) params.forEachIndexed { index, (key , value) ->
if (key.isNullOrEmpty()) {
params.forEachIndexed { index, param -> if (index < paramOrder.size) {
val parts = param.split("=") if (value != null) {
when { paramDict[paramOrder[index]] = value
parts.size == 2 -> { }
val (key, value) = parts }
if (key in paramOrder) paramMap[key] = value } else {
else extraParams[key] = value if (value != null) {
extraParams[key] = value
} }
index < paramOrder.size -> paramMap[paramOrder[index]] = parts.firstOrNull() ?: ""
} }
} }
return buildString { extraParams["i"] = "0.3"
append(baseUrl) extraParams["sp"] = "0"
append("?")
append(paramOrder.joinToString("&") { "$it=${paramMap[it]}" }) val baseUrl = link.split("?")[0]
append("&")
append(extraParams.map { "${it.key}=${it.value}" }.joinToString("&")) val fixedLink = baseUrl.toHttpUrl().newBuilder()
paramOrder.filter { paramDict.containsKey(it) }.forEach { key ->
fixedLink.addQueryParameter(key, paramDict[key])
} }
extraParams.forEach { (key, value) ->
fixedLink.addQueryParameter(key, value)
}
return fixedLink.build().toString()
} }
private fun getResolution(m3u8Url: String): String { private fun getResolution(m3u8Url: String): String {
@ -98,11 +107,10 @@ class LuluExtractor(private val client: OkHttpClient) {
} }
object JavaScriptUnpacker { object JavaScriptUnpacker {
private val UNPACK_REGEX = Regex( private val UNPACK_REGEX by lazy {
"""}\('(.*)', *(\d+), *(\d+), *'(.*?)'\.split\('\|'\)""", Regex("""\}\('(.*)', *(\d+), *(\d+), *'(.*?)'\.split\('\|'\)""",
RegexOption.DOT_MATCHES_ALL RegexOption.DOT_MATCHES_ALL)
) }
fun unpack(encodedJs: String): String? { fun unpack(encodedJs: String): String? {
val match = UNPACK_REGEX.find(encodedJs) ?: return null val match = UNPACK_REGEX.find(encodedJs) ?: return null
val (payload, radixStr, countStr, symtabStr) = match.destructured val (payload, radixStr, countStr, symtabStr) = match.destructured
@ -121,8 +129,8 @@ object JavaScriptUnpacker {
return Regex("""\b\w+\b""").replace(payload) { mr -> return Regex("""\b\w+\b""").replace(payload) { mr ->
symtab.getOrNull(unbase(mr.value, radix, baseDict)) ?: mr.value symtab.getOrNull(unbase(mr.value, radix, baseDict)) ?: mr.value
}.replace("\\", "") }.replace("\\", "")
}
}
private fun unbase(value: String, radix: Int, dict: Map<Char, Int>): Int { private fun unbase(value: String, radix: Int, dict: Map<Char, Int>): Int {
var result = 0 var result = 0
var multiplier = 1 var multiplier = 1

View file

@ -4,13 +4,12 @@ import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import android.util.Base64 import android.util.Base64
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import uy.kohesive.injekt.injectLazy
import java.nio.charset.Charset import java.nio.charset.Charset
class LycorisCafeExtractor(private val client: OkHttpClient) { class LycorisCafeExtractor(private val client: OkHttpClient) {
@ -19,7 +18,15 @@ class LycorisCafeExtractor(private val client: OkHttpClient) {
private val GETLNKURL = "https://www.lycoris.cafe/api/watch/getLink" private val GETLNKURL = "https://www.lycoris.cafe/api/watch/getLink"
private val json: Json by injectLazy() private val wordsRegex by lazy {
Regex(
"""\\U([0-9a-fA-F]{8})|""" + // \UXXXXXXXX
"""\\u([0-9a-fA-F]{4})|""" + // \uXXXX
"""\\x([0-9a-fA-F]{2})|""" + // \xHH
"""\\([0-7]{1,3})|""" + // \OOO (octal)
"""\\([btnfr"'$\\])""" // \n, \t, itd.
)
}
// Credit: https://github.com/skoruppa/docchi-stremio-addon/blob/main/app/players/lycoris.py // Credit: https://github.com/skoruppa/docchi-stremio-addon/blob/main/app/players/lycoris.py
fun getVideosFromUrl(url: String, headers: Headers, prefix: String): List<Video> { fun getVideosFromUrl(url: String, headers: Headers, prefix: String): List<Video> {
@ -33,72 +40,63 @@ class LycorisCafeExtractor(private val client: OkHttpClient) {
GET(url, headers = embedHeaders), GET(url, headers = embedHeaders),
).execute().asJsoup() ).execute().asJsoup()
val scripts = document.select("script") val script = document.selectFirst("script[type='application/json']")?.data() ?: return emptyList()
val episodeDataPattern = Regex("episodeInfo\\s*:\\s*(\\{.*?\\}),", RegexOption.DOT_MATCHES_ALL) val scriptData = script.parseAs<ScriptBody>()
var episodeData: String? = null
for (script in scripts) { val data = scriptData.body.parseAs<ScriptEpisode>()
val content = script.data()
val match = episodeDataPattern.find(content)
if (match != null) { val linkList = data.episodeInfo.id?.let {
episodeData = match.groupValues[1] fetchAndDecodeVideo(client, data.episodeInfo.id.toString(), isSecondary = false)
break
}
} }
val result = mutableMapOf<String, String?>() val fhdLink = data.episodeInfo.FHD?.let {
fetchAndDecodeVideo(client, data.episodeInfo.FHD, isSecondary = true)
val patterns = listOf( }
"id" to Regex("id\\s*:\\s*(\\d+)"), val sdLink = data.episodeInfo.SD?.let {
"FHD" to Regex("FHD\\s*:\\s*\"([^\"]+)\""), fetchAndDecodeVideo(client, data.episodeInfo.SD, isSecondary = true)
"HD" to Regex("HD\\s*:\\s*\"([^\"]+)\""), }
"SD" to Regex("SD\\s*:\\s*\"([^\"]+)\"") val hdLink = data.episodeInfo.HD?.let {
) fetchAndDecodeVideo(client, data.episodeInfo.HD, isSecondary = true)
patterns.forEach { (key, pattern) ->
result[key] = episodeData?.let { pattern.find(it)?.groups?.get(1)?.value }
} }
var linkList: String? = fetchAndDecodeVideo(client, result["id"].toString(), isSecondary = false).toString()
val fhdLink = fetchAndDecodeVideo(client, result["FHD"].toString(), isSecondary = true).toString()
val sdLink = fetchAndDecodeVideo(client, result["SD"].toString(), isSecondary = true).toString()
val hdLink = fetchAndDecodeVideo(client, result["HD"].toString(), isSecondary = true).toString()
if (linkList.isNullOrBlank() || linkList == "{}") { if (linkList.isNullOrBlank() || linkList == "{}") {
if (fhdLink.isNotEmpty()) { if (!fhdLink.isNullOrBlank()) {
videos.add(Video(fhdLink, "${prefix}lycoris.cafe - 1080p", fhdLink)) videos.add(Video(fhdLink, "${prefix}lycoris.cafe - 1080p", fhdLink))
} }
if (hdLink.isNotEmpty()) { if (!hdLink.isNullOrBlank()) {
videos.add(Video(hdLink, "${prefix}lycoris.cafe - 720p", hdLink)) videos.add(Video(hdLink, "${prefix}lycoris.cafe - 720p", hdLink))
} }
if (sdLink.isNotEmpty()) { if (!sdLink.isNullOrBlank()) {
videos.add(Video(sdLink, "${prefix}lycoris.cafe - 480p", sdLink)) videos.add(Video(sdLink, "${prefix}lycoris.cafe - 480p", sdLink))
} }
}else { } else {
val videoLinks = Json.decodeFromString<VideoLinks>(linkList) val videoLinks = linkList.parseAs<VideoLinksApi>()
videoLinks.FHD?.takeIf { checkLinks(client, it) }?.let { videoLinks.FHD?.takeIf { checkLinks(client, it) }?.let {
videos.add(Video(it, "${prefix}lycoris.cafe - 1080p", it)) videos.add(Video(it, "${prefix}lycoris.cafe - 1080p", it))
}?: videos.add(Video(fhdLink, "${prefix}lycoris.cafe - 1080p", fhdLink)) } ?: fhdLink?.takeIf { checkLinks(client, it) }?.let {
videos.add(Video(it, "${prefix}lycoris.cafe - 1080p", it))
}
videoLinks.HD?.takeIf { checkLinks(client, it) }?.let { videoLinks.HD?.takeIf { checkLinks(client, it) }?.let {
videos.add(Video(it, "${prefix}lycoris.cafe - 720p", it)) videos.add(Video(it, "${prefix}lycoris.cafe - 720p", it))
}?: videos.add(Video(hdLink, "${prefix}lycoris.cafe - 720p", hdLink)) } ?: hdLink?.takeIf { checkLinks(client, it) }?.let {
videos.add(Video(it, "${prefix}lycoris.cafe - 720p", it))
}
videoLinks.SD?.takeIf { checkLinks(client, it) }?.let { videoLinks.SD?.takeIf { checkLinks(client, it) }?.let {
videos.add(Video(it, "${prefix}lycoris.cafe - 480p", it)) videos.add(Video(it, "${prefix}lycoris.cafe - 480p", it))
}?: videos.add(Video(sdLink, "${prefix}lycoris.cafe - 480p", sdLink)) } ?: sdLink?.takeIf { checkLinks(client, it) }?.let {
videos.add(Video(it, "${prefix}lycoris.cafe - 480p", it))
}
} }
return videos return videos
} }
private fun decodeVideoLinks(encodedUrl: String?): Any? { private fun decodeVideoLinks(encodedUrl: String): String? {
if (encodedUrl.isNullOrEmpty()) { if (encodedUrl.isBlank()) {
return null return null
} }
@ -121,7 +119,7 @@ class LycorisCafeExtractor(private val client: OkHttpClient) {
} }
} }
private fun fetchAndDecodeVideo(client: OkHttpClient, episodeId: String, isSecondary: Boolean = false): Any? { private fun fetchAndDecodeVideo(client: OkHttpClient, episodeId: String, isSecondary: Boolean = false): String? {
val url: HttpUrl val url: HttpUrl
if (isSecondary) { if (isSecondary) {
@ -130,22 +128,24 @@ class LycorisCafeExtractor(private val client: OkHttpClient) {
val finalText = unicodeEscape.toByteArray(Charsets.ISO_8859_1).toString(Charsets.UTF_8) val finalText = unicodeEscape.toByteArray(Charsets.ISO_8859_1).toString(Charsets.UTF_8)
url = GETLNKURL.toHttpUrl().newBuilder() url = GETLNKURL.toHttpUrl().newBuilder()
?.addQueryParameter("link", finalText) .addQueryParameter("link", finalText)
?.build() ?: throw IllegalStateException("Invalid URL") .build()
} else { } else {
url = GETSECONDARYURL.toHttpUrl().newBuilder() url = GETSECONDARYURL.toHttpUrl().newBuilder()
?.addQueryParameter("id", episodeId) .addQueryParameter("id", episodeId)
?.build() ?: throw IllegalStateException("Invalid URL") .build()
} }
client.newCall(GET(url)) client.newCall(GET(url))
.execute() .execute()
.use { response -> .use { response ->
val data = response.body.string() ?: "" val data = response.body.string()
return decodeVideoLinks(data) return decodeVideoLinks(data)
} }
} }
private fun checkLinks(client: OkHttpClient, link: String): Boolean { private fun checkLinks(client: OkHttpClient, link: String): Boolean {
if (!link.contains("https://")) return false
client.newCall(GET(link)).execute().use { response -> client.newCall(GET(link)).execute().use { response ->
return response.code.toString() == "200" return response.code.toString() == "200"
} }
@ -155,16 +155,7 @@ class LycorisCafeExtractor(private val client: OkHttpClient) {
// 1. Obsługa kontynuacji linii (backslash + newline) // 1. Obsługa kontynuacji linii (backslash + newline)
val withoutLineContinuation = text.replace("\\\n", "") val withoutLineContinuation = text.replace("\\\n", "")
// 2. Regex do wykrywania wszystkich sekwencji escape return wordsRegex.replace(withoutLineContinuation) { match ->
val regex = Regex(
"""\\U([0-9a-fA-F]{8})|""" + // \UXXXXXXXX
"""\\u([0-9a-fA-F]{4})|""" + // \uXXXX
"""\\x([0-9a-fA-F]{2})|""" + // \xHH
"""\\([0-7]{1,3})|""" + // \OOO (octal)
"""\\([btnfr"'$\\\\])""" // \n, \t, itd.
)
return regex.replace(withoutLineContinuation) { match ->
val (u8, u4, x2, octal, simple) = match.destructured val (u8, u4, x2, octal, simple) = match.destructured
when { when {
u8.isNotEmpty() -> handleUnicode8(u8) u8.isNotEmpty() -> handleUnicode8(u8)
@ -208,16 +199,32 @@ class LycorisCafeExtractor(private val client: OkHttpClient) {
} }
@Serializable @Serializable
data class VideoLinks( data class ScriptBody(
val body: String
)
@Serializable
data class ScriptEpisode(
val episodeInfo: EpisodeInfo
)
@Serializable
data class EpisodeInfo(
val id: Int? = null,
val FHD: String? = null,
val HD: String? = null,
val SD: String? = null,
)
@Serializable
data class VideoLinksApi(
val HD: String? = null, val HD: String? = null,
val SD: String? = null, val SD: String? = null,
val FHD: String? = null, val FHD: String? = null,
val Source: String? = null, val Source: String? = null,
val preview: String? = null,
val SourceMKV: String? = null val SourceMKV: String? = null
) )
} }

View file

@ -1,7 +1,7 @@
ext { ext {
extName = 'Docchi' extName = 'Docchi'
extClass = '.Docchi' extClass = '.Docchi'
extVersionCode = 2 extVersionCode = 3
isNsfw = true isNsfw = true
} }

View file

@ -137,7 +137,7 @@ class Docchi : ConfigurableAnimeSource, AnimeHttpSource() {
private val sibnetExtractor by lazy { SibnetExtractor(client) } private val sibnetExtractor by lazy { SibnetExtractor(client) }
private val doodExtractor by lazy { DoodExtractor(client) } private val doodExtractor by lazy { DoodExtractor(client) }
private val lycorisExtractor by lazy { LycorisCafeExtractor(client) } private val lycorisExtractor by lazy { LycorisCafeExtractor(client) }
private val luluExtractor by lazy { LuluExtractor(client) } private val luluExtractor by lazy { LuluExtractor(client, headers) }
private val googledriveExtractor by lazy { GoogleDriveExtractor(client, headers) } private val googledriveExtractor by lazy { GoogleDriveExtractor(client, headers) }
override fun videoListParse(response: Response): List<Video> { override fun videoListParse(response: Response): List<Video> {
@ -278,7 +278,7 @@ class Docchi : ConfigurableAnimeSource, AnimeHttpSource() {
val genres: List<String>, val genres: List<String>,
val broadcast_day: String?, val broadcast_day: String?,
val aired_from: String?, val aired_from: String?,
val episodes: Int, val episodes: Int?,
val season: String, val season: String,
val season_year: Int, val season_year: Int,
val series_type: String, val series_type: String,
@ -294,7 +294,7 @@ class Docchi : ConfigurableAnimeSource, AnimeHttpSource() {
val cover: String, val cover: String,
val adult_content: String, val adult_content: String,
val series_type: String, val series_type: String,
val episodes: Int, val episodes: Int?,
val season: String, val season: String,
val season_year: Int, val season_year: Int,
) )
@ -315,7 +315,7 @@ class Docchi : ConfigurableAnimeSource, AnimeHttpSource() {
val genres: List<String>, val genres: List<String>,
val broadcast_day: String?, val broadcast_day: String?,
val aired_from: String?, val aired_from: String?,
val episodes: Int, val episodes: Int?,
val season: String, val season: String,
val season_year: Int, val season_year: Int,
val series_type: String, val series_type: String,