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

Merged
Hayanek merged 14 commits from fix-lycoris into main 2025-04-06 13:41:23 -05:00
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.network.GET
import okhttp3.Headers
cuong-tran commented 2025-04-02 11:49:07 -05:00 (Migrated from github.com)
        .add("Referer", "https://luluvdo.com/")
```suggestion .add("Referer", "https://luluvdo.com/") ```
cuong-tran commented 2025-04-02 11:50:18 -05:00 (Migrated from github.com)
            val quality = getResolution(fixedUrl)
```suggestion val quality = getResolution(fixedUrl) ```
import okhttp3.HttpUrl.Companion.toHttpUrl
cuong-tran commented 2025-03-25 21:55:14 -05:00 (Migrated from github.com)

It's exact the same as the old headers, why need a new one?
Also, where it was, outside, is better. Same as the Regex one.

It's exact the same as the old headers, why need a new one? Also, where it was, outside, is better. Same as the Regex one.
Hayanek commented 2025-03-25 22:36:05 -05:00 (Migrated from github.com)

Well, not entirely the same if the current one has a user-agent from the application and the previous form relied solely on these 2 headers.
I don't understand which Regex you mean :(

Well, not entirely the same if the current one has a user-agent from the application and the previous form relied solely on these 2 headers. I don't understand which Regex you mean :(
cuong-tran commented 2025-03-26 03:35:20 -05:00 (Migrated from github.com)

Just change the Headers.Builder() to headers.Builder() then. Keep it outside like you did with the wordRegex previously.
if you meant creating a header based on parameter headers passed in to the function, find where the actual function called and restructure it so the original caller should pass the existed lulu one.

Just change the `Headers.Builder()` to `headers.Builder()` then. Keep it outside like you did with the wordRegex previously. if you meant creating a header based on parameter `headers` passed in to the function, find where the actual function called and restructure it so the original caller should pass the existed lulu one.
import okhttp3.OkHttpClient
import java.util.regex.Pattern
class LuluExtractor(private val client: OkHttpClient) {
class LuluExtractor(private val client: OkHttpClient, headers: Headers) {
private val headers = Headers.Builder()
.add("Referer", "https://luluvdo.com")
private val headers = headers.newBuilder()
.add("Referer", "https://luluvdo.com/")
.add("Origin", "https://luluvdo.com")
.build()
//Credit: https://github.com/skoruppa/docchi-stremio-addon/blob/main/app/players/lulustream.py
fun videosFromUrl(url: String, prefix: String): List<Video> {
val videos = mutableListOf<Video>()
@ -22,7 +24,7 @@ class LuluExtractor(private val client: OkHttpClient) {
val fixedUrl = fixM3u8Link(m3u8Url)
val quality = getResolution(fixedUrl)
videos.add(Video(fixedUrl, "${prefix}Lulu - $quality", fixedUrl))
videos.add(Video(fixedUrl, "${prefix}Lulu - $quality", fixedUrl, headers))
} catch (e: Exception) {
e.printStackTrace()
}
@ -50,34 +52,41 @@ class LuluExtractor(private val client: OkHttpClient) {
private fun fixM3u8Link(link: String): String {
val paramOrder = listOf("t", "s", "e", "f")
val baseUrl = link.split("?").first()
val params = link.split("?").getOrNull(1)?.split("&") ?: emptyList()
val paramMap = mutableMapOf<String, String>()
val extraParams = mutableMapOf(
"i" to "0.3",
"sp" to "0"
)
params.forEachIndexed { index, param ->
val parts = param.split("=")
when {
parts.size == 2 -> {
val (key, value) = parts
if (key in paramOrder) paramMap[key] = value
else extraParams[key] = value
val params = Pattern.compile("[?&]([^=]*)=([^&]*)").matcher(link).let { matcher ->
generateSequence { if (matcher.find()) matcher.group(1) to matcher.group(2) else null }.toList()
}
val paramDict = mutableMapOf<String, String>()
val extraParams = mutableMapOf<String, String>()
params.forEachIndexed { index, (key , value) ->
if (key.isNullOrEmpty()) {
if (index < paramOrder.size) {
if (value != null) {
paramDict[paramOrder[index]] = value
}
}
} else {
if (value != null) {
extraParams[key] = value
}
index < paramOrder.size -> paramMap[paramOrder[index]] = parts.firstOrNull() ?: ""
}
}
return buildString {
append(baseUrl)
append("?")
append(paramOrder.joinToString("&") { "$it=${paramMap[it]}" })
append("&")
append(extraParams.map { "${it.key}=${it.value}" }.joinToString("&"))
extraParams["i"] = "0.3"
extraParams["sp"] = "0"
val baseUrl = link.split("?")[0]
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 {
@ -98,11 +107,10 @@ class LuluExtractor(private val client: OkHttpClient) {
}
object JavaScriptUnpacker {
private val UNPACK_REGEX = Regex(
"""}\('(.*)', *(\d+), *(\d+), *'(.*?)'\.split\('\|'\)""",
RegexOption.DOT_MATCHES_ALL
)
private val UNPACK_REGEX by lazy {
Regex("""\}\('(.*)', *(\d+), *(\d+), *'(.*?)'\.split\('\|'\)""",
RegexOption.DOT_MATCHES_ALL)
}
fun unpack(encodedJs: String): String? {
val match = UNPACK_REGEX.find(encodedJs) ?: return null
val (payload, radixStr, countStr, symtabStr) = match.destructured
@ -121,8 +129,8 @@ object JavaScriptUnpacker {
return Regex("""\b\w+\b""").replace(payload) { mr ->
symtab.getOrNull(unbase(mr.value, radix, baseDict)) ?: mr.value
}.replace("\\", "")
}
}
private fun unbase(value: String, radix: Int, dict: Map<Char, Int>): Int {
var result = 0
var multiplier = 1

View file

@ -4,13 +4,12 @@ import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import android.util.Base64
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.Serializable
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import uy.kohesive.injekt.injectLazy
import java.nio.charset.Charset
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 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
cuong-tran commented 2025-03-12 11:51:48 -05:00 (Migrated from github.com)

this will send a string "null" to json.decode and cause a crash

this will send a string `"null"` to json.decode and cause a crash
cuong-tran commented 2025-03-12 11:52:59 -05:00 (Migrated from github.com)

use script.parseAs<ScriptBody>()

use `script.parseAs<ScriptBody>()`
cuong-tran commented 2025-03-12 11:53:47 -05:00 (Migrated from github.com)

same here

same here
cuong-tran commented 2025-03-12 11:54:37 -05:00 (Migrated from github.com)
        val linkList: String? = fetchAndDecodeVideo(client, data.episodeInfo.id.toString(), isSecondary = false)

also fix fetchAndDecodeVideo() & decodeVideoLinks() so they both return String?

```suggestion val linkList: String? = fetchAndDecodeVideo(client, data.episodeInfo.id.toString(), isSecondary = false) ``` also fix `fetchAndDecodeVideo()` & `decodeVideoLinks()` so they both return `String?`
cuong-tran commented 2025-03-12 12:18:42 -05:00 (Migrated from github.com)
        val fhdLink = fetchAndDecodeVideo(client, data.episodeInfo.FHD.toString(), isSecondary = true)
        val sdLink = fetchAndDecodeVideo(client, data.episodeInfo.SD.toString(), isSecondary = true)
        val hdLink = fetchAndDecodeVideo(client, data.episodeInfo.HD.toString(), isSecondary = true)
```suggestion val fhdLink = fetchAndDecodeVideo(client, data.episodeInfo.FHD.toString(), isSecondary = true) val sdLink = fetchAndDecodeVideo(client, data.episodeInfo.SD.toString(), isSecondary = true) val hdLink = fetchAndDecodeVideo(client, data.episodeInfo.HD.toString(), isSecondary = true) ```
cuong-tran commented 2025-03-12 12:27:09 -05:00 (Migrated from github.com)
            if (!fhdLink.isNullOrBlank()) {
```suggestion if (!fhdLink.isNullOrBlank()) { ```
Hayanek commented 2025-03-12 12:49:49 -05:00 (Migrated from github.com)

with this I was aware of it but had no idea how to fix it so that such a problem would not occur

with this I was aware of it but had no idea how to fix it so that such a problem would not occur
cuong-tran commented 2025-03-12 23:47:41 -05:00 (Migrated from github.com)

Add ?. before toString and conditional action if it's null

Add `?.` before `toString` and conditional action if it's null
fun getVideosFromUrl(url: String, headers: Headers, prefix: String): List<Video> {
@ -33,72 +40,63 @@ class LycorisCafeExtractor(private val client: OkHttpClient) {
GET(url, headers = embedHeaders),
).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)
var episodeData: String? = null
val scriptData = script.parseAs<ScriptBody>()
for (script in scripts) {
val content = script.data()
val match = episodeDataPattern.find(content)
val data = scriptData.body.parseAs<ScriptEpisode>()
if (match != null) {
episodeData = match.groupValues[1]
break
}
val linkList = data.episodeInfo.id?.let {
fetchAndDecodeVideo(client, data.episodeInfo.id.toString(), isSecondary = false)
}
val result = mutableMapOf<String, String?>()
val patterns = listOf(
"id" to Regex("id\\s*:\\s*(\\d+)"),
"FHD" to Regex("FHD\\s*:\\s*\"([^\"]+)\""),
"HD" to Regex("HD\\s*:\\s*\"([^\"]+)\""),
"SD" to Regex("SD\\s*:\\s*\"([^\"]+)\"")
)
patterns.forEach { (key, pattern) ->
result[key] = episodeData?.let { pattern.find(it)?.groups?.get(1)?.value }
val fhdLink = data.episodeInfo.FHD?.let {
cuong-tran commented 2025-03-12 12:15:19 -05:00 (Migrated from github.com)
        } else {
```suggestion } else { ```
cuong-tran commented 2025-03-12 12:21:44 -05:00 (Migrated from github.com)
            val videoLinks = linkList.parseAs<VideoLinksApi>()
```suggestion val videoLinks = linkList.parseAs<VideoLinksApi>() ```
cuong-tran commented 2025-03-12 12:27:35 -05:00 (Migrated from github.com)
            if (!sdLink.isNullOrBlank()) {
```suggestion if (!sdLink.isNullOrBlank()) { ```
cuong-tran commented 2025-03-12 12:28:12 -05:00 (Migrated from github.com)
            }?: fhdLink?.takeIf { it.contains("https://") }?.let {
```suggestion }?: fhdLink?.takeIf { it.contains("https://") }?.let { ```
cuong-tran commented 2025-03-12 12:28:33 -05:00 (Migrated from github.com)
            }?: hdLink?.takeIf { it.contains("https://") }?.let {
```suggestion }?: hdLink?.takeIf { it.contains("https://") }?.let { ```
cuong-tran commented 2025-03-12 12:28:49 -05:00 (Migrated from github.com)
            }?: sdLink?.takeIf { it.contains("https://") }?.let {
```suggestion }?: sdLink?.takeIf { it.contains("https://") }?.let { ```
fetchAndDecodeVideo(client, data.episodeInfo.FHD, isSecondary = true)
}
val sdLink = data.episodeInfo.SD?.let {
fetchAndDecodeVideo(client, data.episodeInfo.SD, isSecondary = true)
}
val hdLink = data.episodeInfo.HD?.let {
fetchAndDecodeVideo(client, data.episodeInfo.HD, isSecondary = true)
}
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 (fhdLink.isNotEmpty()) {
if (!fhdLink.isNullOrBlank()) {
videos.add(Video(fhdLink, "${prefix}lycoris.cafe - 1080p", fhdLink))
}
if (hdLink.isNotEmpty()) {
if (!hdLink.isNullOrBlank()) {
videos.add(Video(hdLink, "${prefix}lycoris.cafe - 720p", hdLink))
}
if (sdLink.isNotEmpty()) {
if (!sdLink.isNullOrBlank()) {
videos.add(Video(sdLink, "${prefix}lycoris.cafe - 480p", sdLink))
}
}else {
val videoLinks = Json.decodeFromString<VideoLinks>(linkList)
} else {
val videoLinks = linkList.parseAs<VideoLinksApi>()
videoLinks.FHD?.takeIf { checkLinks(client, it) }?.let {
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 {
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 {
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
}
private fun decodeVideoLinks(encodedUrl: String?): Any? {
if (encodedUrl.isNullOrEmpty()) {
private fun decodeVideoLinks(encodedUrl: String): String? {
if (encodedUrl.isBlank()) {
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
if (isSecondary) {
@ -130,22 +128,24 @@ class LycorisCafeExtractor(private val client: OkHttpClient) {
val finalText = unicodeEscape.toByteArray(Charsets.ISO_8859_1).toString(Charsets.UTF_8)
url = GETLNKURL.toHttpUrl().newBuilder()
?.addQueryParameter("link", finalText)
?.build() ?: throw IllegalStateException("Invalid URL")
.addQueryParameter("link", finalText)
.build()
} else {
url = GETSECONDARYURL.toHttpUrl().newBuilder()
?.addQueryParameter("id", episodeId)
?.build() ?: throw IllegalStateException("Invalid URL")
.addQueryParameter("id", episodeId)
.build()
}
client.newCall(GET(url))
.execute()
.use { response ->
val data = response.body.string() ?: ""
val data = response.body.string()
return decodeVideoLinks(data)
}
}
private fun checkLinks(client: OkHttpClient, link: String): Boolean {
if (!link.contains("https://")) return false
cuong-tran commented 2025-03-24 01:06:48 -05:00 (Migrated from github.com)

Move regex outside and make it lazy load

Move regex outside and make it lazy load
Hayanek commented 2025-03-24 10:42:33 -05:00 (Migrated from github.com)

I'll be honest I have no idea why, and how I could do it.

I'll be honest I have no idea why, and how I could do it.
cuong-tran commented 2025-03-24 15:26:12 -05:00 (Migrated from github.com)

move out so it's only needed to be initialized once, not every time the function is called.

Example:

private val wordRegex by lazy { Regex("""\w+""") }
move out so it's only needed to be initialized once, not every time the function is called. Example: ```kotlin private val wordRegex by lazy { Regex("""\w+""") } ```
Hayanek commented 2025-03-24 16:10:12 -05:00 (Migrated from github.com)

Will it be good this way?

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.
        ) 
    }

or do I have to knock them all down one by one :(

Will it be good this way? ```kotlin 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. ) } ``` or do I have to knock them all down one by one :(
cuong-tran commented 2025-03-24 22:24:12 -05:00 (Migrated from github.com)

Just move the whole thing out like that is OK

Just move the whole thing out like that is OK
client.newCall(GET(link)).execute().use { response ->
return response.code.toString() == "200"
}
@ -155,16 +155,7 @@ class LycorisCafeExtractor(private val client: OkHttpClient) {
// 1. Obsługa kontynuacji linii (backslash + newline)
val withoutLineContinuation = text.replace("\\\n", "")
// 2. Regex do wykrywania wszystkich sekwencji escape
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 ->
return wordsRegex.replace(withoutLineContinuation) { match ->
val (u8, u4, x2, octal, simple) = match.destructured
when {
u8.isNotEmpty() -> handleUnicode8(u8)
@ -208,16 +199,32 @@ class LycorisCafeExtractor(private val client: OkHttpClient) {
}
@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 SD: String? = null,
val FHD: String? = null,
val Source: String? = null,
val preview: String? = null,
val SourceMKV: String? = null
)
}

View file

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

View file

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