From 480923433e426241c145f3c7efa8d493c3bda4fa Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Tue, 30 Jun 2026 20:40:40 +0200
Subject: [PATCH 01/94] Fix(TV): Crash issue due to missing xml, Closes #2980
---
app/src/main/res/layout/repository_item_tv.xml | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/app/src/main/res/layout/repository_item_tv.xml b/app/src/main/res/layout/repository_item_tv.xml
index 7ef3b04db..cf2ee0c41 100644
--- a/app/src/main/res/layout/repository_item_tv.xml
+++ b/app/src/main/res/layout/repository_item_tv.xml
@@ -106,6 +106,13 @@
android:textSize="12sp"
tools:text="https://github.com/..." />
+
Date: Tue, 30 Jun 2026 20:40:58 +0200
Subject: [PATCH 02/94] Fix(TV): Crash issue due to missing xml, Closes #2980
(#2981)
---
app/src/main/res/layout/repository_item_tv.xml | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/app/src/main/res/layout/repository_item_tv.xml b/app/src/main/res/layout/repository_item_tv.xml
index 7ef3b04db..cf2ee0c41 100644
--- a/app/src/main/res/layout/repository_item_tv.xml
+++ b/app/src/main/res/layout/repository_item_tv.xml
@@ -106,6 +106,13 @@
android:textSize="12sp"
tools:text="https://github.com/..." />
+
Date: Thu, 2 Jul 2026 18:29:35 +0300
Subject: [PATCH 03/94] Focus "Add Repo" by Right click on Extensions page
(#2986)
---
app/src/main/res/layout/fragment_downloads.xml | 4 ++--
app/src/main/res/layout/fragment_extensions.xml | 2 ++
app/src/main/res/layout/repository_item_tv.xml | 1 +
3 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/app/src/main/res/layout/fragment_downloads.xml b/app/src/main/res/layout/fragment_downloads.xml
index 96e1e50ab..ac5d383df 100644
--- a/app/src/main/res/layout/fragment_downloads.xml
+++ b/app/src/main/res/layout/fragment_downloads.xml
@@ -261,7 +261,7 @@
android:padding="8dp"
android:src="@drawable/ic_network_stream"
- app:tint="?attr/textColor">
+ app:tint="?attr/textColor" />
@@ -306,7 +306,7 @@
android:paddingBottom="100dp"
android:clipToPadding="false"
android:descendantFocusability="afterDescendants"
- android:nextFocusUp="@id/download_appbar"
+ android:nextFocusUp="@id/download_stream_button_tv"
android:nextFocusLeft="@id/navigation_downloads"
android:nextFocusDown="@id/download_queue_button"
android:tag="@string/tv_no_focus_tag"
diff --git a/app/src/main/res/layout/fragment_extensions.xml b/app/src/main/res/layout/fragment_extensions.xml
index ccb56df15..6dd2d8662 100644
--- a/app/src/main/res/layout/fragment_extensions.xml
+++ b/app/src/main/res/layout/fragment_extensions.xml
@@ -241,6 +241,8 @@
android:focusable="true"
android:nextFocusLeft="@id/plugin_storage_appbar"
android:nextFocusUp="@id/repo_recycler_view"
+ android:nextFocusRight="@id/add_repo_button_imageview"
+ android:nextFocusDown="@id/add_repo_button_imageview"
android:src="@drawable/ic_baseline_add_24"
app:tint="?attr/textColor" />
diff --git a/app/src/main/res/layout/repository_item_tv.xml b/app/src/main/res/layout/repository_item_tv.xml
index cf2ee0c41..53d5386b1 100644
--- a/app/src/main/res/layout/repository_item_tv.xml
+++ b/app/src/main/res/layout/repository_item_tv.xml
@@ -141,6 +141,7 @@
android:contentDescription="@string/download"
android:focusable="true"
android:nextFocusLeft="@id/action_settings"
+ android:nextFocusRight="@id/add_repo_button_imageview"
android:padding="12dp"
tools:src="@drawable/ic_baseline_add_24" />
From 971027d2d1eeedd9dcf3dcf42b8a380a09b2cc2f Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Thu, 2 Jul 2026 15:26:54 -0600
Subject: [PATCH 04/94] [skip ci] Add JvmName import to MainAPI (#2988)
Necessary for non JVM platforms or it won't resolve. On non JVM platforms the annotation is simply erased at compile time but still compiled as long as the import is present.
---
.../src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt | 1 +
1 file changed, 1 insertion(+)
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
index 301673320..086a97ef1 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
@@ -41,6 +41,7 @@ import kotlinx.datetime.format.parse
import kotlinx.datetime.toInstant
import kotlinx.serialization.json.Json
import kotlin.io.encoding.Base64
+import kotlin.jvm.JvmName
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
import kotlin.time.Clock
From 56c6400fd9c54ca8fedbdd88984154b17ae3a1be Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Thu, 2 Jul 2026 15:27:46 -0600
Subject: [PATCH 05/94] [skip ci] Remove unnecessary ?. (#2982)
It is a build warning
---
.../cloudstream3/ui/settings/extensions/PluginsViewModel.kt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt
index c4f5ac396..09c202d43 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt
@@ -238,7 +238,7 @@ class PluginsViewModel : ViewModel() {
if (it.pluginWrapper.plugin.language == null) {
return@filter selectedLanguages.contains("none")
}
- selectedLanguages.contains(it.pluginWrapper.plugin.language?.lowercase())
+ selectedLanguages.contains(it.pluginWrapper.plugin.language.lowercase())
}
}
From aaa87c1ccc607038046d91de11e3e418f2b61647 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Thu, 2 Jul 2026 15:31:41 -0600
Subject: [PATCH 06/94] LibraryFragment: migrate to kotlinx serialization
(#2972)
---
.../cloudstream3/ui/library/LibraryFragment.kt | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt
index c5f8fa3d9..e73a8c75a 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt
@@ -20,6 +20,7 @@ import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.RecyclerView
+import com.fasterxml.jackson.annotation.JsonProperty
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
import com.lagradost.cloudstream3.APIHolder
@@ -52,12 +53,13 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.math.abs
const val LIBRARY_FOLDER = "library_folder"
-
enum class LibraryOpenerType(@StringRes val stringRes: Int) {
Default(R.string.action_default),
Provider(R.string.none),
@@ -67,13 +69,15 @@ enum class LibraryOpenerType(@StringRes val stringRes: Int) {
}
/** Used to store how the user wants to open said poster */
+@Serializable
data class LibraryOpener(
- val openType: LibraryOpenerType,
- val providerData: ProviderLibraryData?,
+ @JsonProperty("openType") @SerialName("openType") val openType: LibraryOpenerType,
+ @JsonProperty("providerData") @SerialName("providerData") val providerData: ProviderLibraryData?,
)
+@Serializable
data class ProviderLibraryData(
- val apiName: String
+ @JsonProperty("apiName") @SerialName("apiName") val apiName: String,
)
class LibraryFragment : BaseFragment(
@@ -568,4 +572,4 @@ class LibraryFragment : BaseFragment(
}
}
-class MenuSearchView(context: Context) : SearchView(context)
\ No newline at end of file
+class MenuSearchView(context: Context) : SearchView(context)
From 60da132d3f80aaa3d945b8eabaea398d200f811c Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Thu, 2 Jul 2026 15:40:57 -0600
Subject: [PATCH 07/94] [skip ci] MyDramaList: migrate to kotlinx serialization
(#2870)
---
.../cloudstream3/metaproviders/MyDramaList.kt | 238 +++++++++---------
1 file changed, 126 insertions(+), 112 deletions(-)
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/MyDramaList.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/MyDramaList.kt
index e7e1175f0..a28e1c37b 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/MyDramaList.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/metaproviders/MyDramaList.kt
@@ -29,10 +29,13 @@ import com.lagradost.cloudstream3.newTvSeriesLoadResponse
import com.lagradost.cloudstream3.newTvSeriesSearchResponse
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.toJson
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonElement
import okhttp3.Interceptor
import okhttp3.Response
-//Reference: https://mydramalist.github.io/MDL-API/
+// Reference: https://mydramalist.github.io/MDL-API/
abstract class MyDramaListAPI : MainAPI() {
override var name = "MyDramaList"
override val hasMainPage = true
@@ -77,7 +80,7 @@ abstract class MyDramaListAPI : MainAPI() {
val list = app.get(
url = "${request.data}&limit=20&page=$page&lang=en-US",
interceptor = headerInterceptor
- ).parsed().map { element ->
+ ).parsed>().map { element ->
element.toSearchResponse()
}
return newHomePageResponse(request.name, list)
@@ -88,7 +91,7 @@ abstract class MyDramaListAPI : MainAPI() {
url = "$API_HOST/search/titles",
data = mapOf("q" to query),
interceptor = headerInterceptor
- ).parsed().map { element ->
+ ).parsed>().map { element ->
element.toSearchResponse()
}
}
@@ -210,69 +213,69 @@ abstract class MyDramaListAPI : MainAPI() {
}
}
+ @Serializable
data class Data(
- val type: TvType? = null,
- val media: MediaSummary? = null,
+ @JsonProperty("type") @SerialName("type") val type: TvType? = null,
+ @JsonProperty("media") @SerialName("media") val media: MediaSummary? = null,
)
- class SearchResult : ArrayList()
-
- class Recommendations : ArrayList()
-
+ @Serializable
data class MediaSummary(
- @JsonProperty("id") val id: Long,
- @JsonProperty("title") val title: String,
- @JsonProperty("original_title") val originalTitle: String,
- @JsonProperty("year") val year: Int? = null,
- @JsonProperty("rating") val rating: Double? = null,
- @JsonProperty("permalink") val permalink: String? = null,
- @JsonProperty("type") val type: String,
- @JsonProperty("media_type") val mediaType: String? = null,
- @JsonProperty("country") val country: String? = null,
- @JsonProperty("language") val language: String? = null,
- @JsonProperty("images") val images: Images,
+ @JsonProperty("id") @SerialName("id") val id: Long,
+ @JsonProperty("title") @SerialName("title") val title: String,
+ @JsonProperty("original_title") @SerialName("original_title") val originalTitle: String,
+ @JsonProperty("year") @SerialName("year") val year: Int? = null,
+ @JsonProperty("rating") @SerialName("rating") val rating: Double? = null,
+ @JsonProperty("permalink") @SerialName("permalink") val permalink: String? = null,
+ @JsonProperty("type") @SerialName("type") val type: String,
+ @JsonProperty("media_type") @SerialName("media_type") val mediaType: String? = null,
+ @JsonProperty("country") @SerialName("country") val country: String? = null,
+ @JsonProperty("language") @SerialName("language") val language: String? = null,
+ @JsonProperty("images") @SerialName("images") val images: Images,
)
+ @Serializable
data class Images(
- @JsonProperty("thumb") val thumb: String? = null,
- @JsonProperty("medium") val medium: String? = null,
- @JsonProperty("poster") val poster: String? = null,
+ @JsonProperty("thumb") @SerialName("thumb") val thumb: String? = null,
+ @JsonProperty("medium") @SerialName("medium") val medium: String? = null,
+ @JsonProperty("poster") @SerialName("poster") val poster: String? = null,
)
+ @Serializable
data class Media(
- @JsonProperty("id") val id: Long,
- @JsonProperty("slug") val slug: String,
- @JsonProperty("title") val title: String,
- @JsonProperty("original_title") val originalTitle: String,
- @JsonProperty("year") val mediaYear: Int,
- @JsonProperty("episodes") val episodes: Long,
- @JsonProperty("rating") val mediaRating: Double,
- @JsonProperty("permalink") val permalink: String? = null,
- @JsonProperty("synopsis") val synopsis: String? = null,
- @JsonProperty("type") val type: String? = null,
- @JsonProperty("media_type") val mediaType: String? = null,
- @JsonProperty("country") val country: String? = null,
- @JsonProperty("language") val language: String? = null,
- @JsonProperty("images") val images: Images,
- @JsonProperty("alt_titles") val altTitles: List? = null,
- @JsonProperty("votes") val votes: Long? = null,
- @JsonProperty("aired_start") val airedStart: String? = null,
- @JsonProperty("released") val released: String? = null,
- @JsonProperty("release_dates_fmt") val releaseDatesFmt: String,
- @JsonProperty("genres") val genres: List? = null,
- @JsonProperty("trailer") val trailer: Trailer?,
- @JsonProperty("watchers") val watchers: Long,
- @JsonProperty("ranked") val ranked: Long,
- @JsonProperty("popularity") val popularity: Long,
- @JsonProperty("runtime") val runtime: Long,
- @JsonProperty("reviews_count") val reviewsCount: Long,
- @JsonProperty("recs_count") val recsCount: Long,
- @JsonProperty("comments_count") val commentsCount: Long,
- @JsonProperty("certification") val certification: String,
- @JsonProperty("status") val status: String,
- @JsonProperty("enable_ads") val enableAds: Boolean,
- @JsonProperty("sources") val sources: List,
- @JsonProperty("updated_at") val updatedAt: Long,
+ @JsonProperty("id") @SerialName("id") val id: Long,
+ @JsonProperty("slug") @SerialName("slug") val slug: String,
+ @JsonProperty("title") @SerialName("title") val title: String,
+ @JsonProperty("original_title") @SerialName("original_title") val originalTitle: String,
+ @JsonProperty("year") @SerialName("year") val mediaYear: Int,
+ @JsonProperty("episodes") @SerialName("episodes") val episodes: Long,
+ @JsonProperty("rating") @SerialName("rating") val mediaRating: Double,
+ @JsonProperty("permalink") @SerialName("permalink") val permalink: String? = null,
+ @JsonProperty("synopsis") @SerialName("synopsis") val synopsis: String? = null,
+ @JsonProperty("type") @SerialName("type") val type: String? = null,
+ @JsonProperty("media_type") @SerialName("media_type") val mediaType: String? = null,
+ @JsonProperty("country") @SerialName("country") val country: String? = null,
+ @JsonProperty("language") @SerialName("language") val language: String? = null,
+ @JsonProperty("images") @SerialName("images") val images: Images,
+ @JsonProperty("alt_titles") @SerialName("alt_titles") val altTitles: List? = null,
+ @JsonProperty("votes") @SerialName("votes") val votes: Long? = null,
+ @JsonProperty("aired_start") @SerialName("aired_start") val airedStart: String? = null,
+ @JsonProperty("released") @SerialName("released") val released: String? = null,
+ @JsonProperty("release_dates_fmt") @SerialName("release_dates_fmt") val releaseDatesFmt: String,
+ @JsonProperty("genres") @SerialName("genres") val genres: List? = null,
+ @JsonProperty("trailer") @SerialName("trailer") val trailer: Trailer?,
+ @JsonProperty("watchers") @SerialName("watchers") val watchers: Long,
+ @JsonProperty("ranked") @SerialName("ranked") val ranked: Long,
+ @JsonProperty("popularity") @SerialName("popularity") val popularity: Long,
+ @JsonProperty("runtime") @SerialName("runtime") val runtime: Long,
+ @JsonProperty("reviews_count") @SerialName("reviews_count") val reviewsCount: Long,
+ @JsonProperty("recs_count") @SerialName("recs_count") val recsCount: Long,
+ @JsonProperty("comments_count") @SerialName("comments_count") val commentsCount: Long,
+ @JsonProperty("certification") @SerialName("certification") val certification: String,
+ @JsonProperty("status") @SerialName("status") val status: String,
+ @JsonProperty("enable_ads") @SerialName("enable_ads") val enableAds: Boolean,
+ @JsonProperty("sources") @SerialName("sources") val sources: List,
+ @JsonProperty("updated_at") @SerialName("updated_at") val updatedAt: Long,
) {
suspend fun fetchCredits(): List {
val actors = app.get(
@@ -290,11 +293,11 @@ abstract class MyDramaListAPI : MainAPI() {
return actors
}
- suspend fun fetchRecommendations(): Recommendations {
+ suspend fun fetchRecommendations(): ArrayList {
return app.get(
url = "$API_HOST/titles/$id/recommendations",
interceptor = headerInterceptor
- ).parsed()
+ ).parsed>()
}
suspend fun fetchTrailer(): String? {
@@ -319,7 +322,7 @@ abstract class MyDramaListAPI : MainAPI() {
return app.get(
url = "$API_HOST/titles/${this.id}/episodes",
interceptor = headerInterceptor
- ).parsed().map {
+ ).parsed>().map {
it.episodes
}.flatten().map { ep ->
val link = LinkData(
@@ -348,95 +351,106 @@ abstract class MyDramaListAPI : MainAPI() {
}
}
+ @Serializable
data class Genre(
- @JsonProperty("id") val id: Long,
- @JsonProperty("name") val name: String,
- @JsonProperty("slug") val slug: String,
+ @JsonProperty("id") @SerialName("id") val id: Long,
+ @JsonProperty("name") @SerialName("name") val name: String,
+ @JsonProperty("slug") @SerialName("slug") val slug: String,
)
+ @Serializable
data class Tag(
- @JsonProperty("id") val id: Long,
- @JsonProperty("name") val name: String,
- @JsonProperty("slug") val slug: String,
+ @JsonProperty("id") @SerialName("id") val id: Long,
+ @JsonProperty("name") @SerialName("name") val name: String,
+ @JsonProperty("slug") @SerialName("slug") val slug: String,
)
+ @Serializable
data class Source(
- @JsonProperty("xid") val xid: String,
- @JsonProperty("name") val name: String,
- @JsonProperty("source") val source: String,
- @JsonProperty("source_type") val sourceType: String,
- @JsonProperty("link") val link: String,
- @JsonProperty("image") val image: String,
+ @JsonProperty("xid") @SerialName("xid") val xid: String,
+ @JsonProperty("name") @SerialName("name") val name: String,
+ @JsonProperty("source") @SerialName("source") val source: String,
+ @JsonProperty("source_type") @SerialName("source_type") val sourceType: String,
+ @JsonProperty("link") @SerialName("link") val link: String,
+ @JsonProperty("image") @SerialName("image") val image: String,
)
+ @Serializable
data class Trailer(
- @JsonProperty("id") val id: Long? = null,
+ @JsonProperty("id") @SerialName("id") val id: Long? = null,
)
+ @Serializable
data class Credits(
- @JsonProperty("cast") val cast: List,
- @JsonProperty("crew") val crew: List,
+ @JsonProperty("cast") @SerialName("cast") val cast: List,
+ @JsonProperty("crew") @SerialName("crew") val crew: List,
)
+ @Serializable
data class Cast(
- @JsonProperty("id") val id: Long,
- @JsonProperty("name") val name: String,
- @JsonProperty("url") val url: String,
- @JsonProperty("slug") val slug: String,
- @JsonProperty("images") val images: Images,
- @JsonProperty("character_name") val characterName: String,
- @JsonProperty("role") val role: String,
+ @JsonProperty("id") @SerialName("id") val id: Long,
+ @JsonProperty("name") @SerialName("name") val name: String,
+ @JsonProperty("url") @SerialName("url") val url: String,
+ @JsonProperty("slug") @SerialName("slug") val slug: String,
+ @JsonProperty("images") @SerialName("images") val images: Images,
+ @JsonProperty("character_name") @SerialName("character_name") val characterName: String,
+ @JsonProperty("role") @SerialName("role") val role: String,
)
+ @Serializable
data class Crew(
- @JsonProperty("id") val id: Long,
- @JsonProperty("name") val name: String,
- @JsonProperty("slug") val slug: String,
- @JsonProperty("images") val images: Images,
- @JsonProperty("job") val job: String,
+ @JsonProperty("id") @SerialName("id") val id: Long,
+ @JsonProperty("name") @SerialName("name") val name: String,
+ @JsonProperty("slug") @SerialName("slug") val slug: String,
+ @JsonProperty("images") @SerialName("images") val images: Images,
+ @JsonProperty("job") @SerialName("job") val job: String,
)
- class ShowEpisodes : ArrayList()
-
+ @Serializable
data class ShowEpisodesItem(
- @JsonProperty("name") val name: String,
- @JsonProperty("release_date") val releaseDate: String,
- @JsonProperty("episodes") val episodes: List,
- @JsonProperty("timezone") val timezone: String,
- @JsonProperty("total") val total: Int,
+ @JsonProperty("name") @SerialName("name") val name: String,
+ @JsonProperty("release_date") @SerialName("release_date") val releaseDate: String,
+ @JsonProperty("episodes") @SerialName("episodes") val episodes: List,
+ @JsonProperty("timezone") @SerialName("timezone") val timezone: String,
+ @JsonProperty("total") @SerialName("total") val total: Int,
)
+ @Serializable
data class ShowEpisode(
- @JsonProperty("id") val id: Int,
- @JsonProperty("episode_number") val episodeNumber: Int,
- @JsonProperty("rating") val rating: Double,
- @JsonProperty("votes") val votes: Int,
- @JsonProperty("released_at") val releasedAt: String,
+ @JsonProperty("id") @SerialName("id") val id: Int,
+ @JsonProperty("episode_number") @SerialName("episode_number") val episodeNumber: Int,
+ @JsonProperty("rating") @SerialName("rating") val rating: Double,
+ @JsonProperty("votes") @SerialName("votes") val votes: Int,
+ @JsonProperty("released_at") @SerialName("released_at") val releasedAt: String,
)
+ @Serializable
data class TrailerRoot(
- @JsonProperty("trailer") val trailer: TrailerNode,
+ @JsonProperty("trailer") @SerialName("trailer") val trailer: TrailerNode,
)
+ @Serializable
data class TrailerNode(
- @JsonProperty("trailer") val trailerDetails: TrailerDetails,
+ @JsonProperty("trailer") @SerialName("trailer") val trailerDetails: TrailerDetails,
)
+ @Serializable
data class TrailerDetails(
- @JsonProperty("id") val id: Long,
- @JsonProperty("source") val source: String,
+ @JsonProperty("id") @SerialName("id") val id: Long,
+ @JsonProperty("source") @SerialName("source") val source: String,
)
+ @Serializable
data class LinkData(
- @JsonProperty("id") val id: Long? = null,
- @JsonProperty("type") val type: String? = null,
- @JsonProperty("season") val season: Int? = null,
- @JsonProperty("episode") val episode: Int? = null,
- @JsonProperty("title") val title: String? = null,
- @JsonProperty("year") val year: Int? = null,
- @JsonProperty("orgTitle") val orgTitle: String? = null,
- @JsonProperty("lastSeason") val lastSeason: Int? = null,
- @JsonProperty("date") val date: String? = null,
- @JsonProperty("airedDate") val airedDate: String? = null,
+ @JsonProperty("id") @SerialName("id") val id: Long? = null,
+ @JsonProperty("type") @SerialName("type") val type: String? = null,
+ @JsonProperty("season") @SerialName("season") val season: Int? = null,
+ @JsonProperty("episode") @SerialName("episode") val episode: Int? = null,
+ @JsonProperty("title") @SerialName("title") val title: String? = null,
+ @JsonProperty("year") @SerialName("year") val year: Int? = null,
+ @JsonProperty("orgTitle") @SerialName("orgTitle") val orgTitle: String? = null,
+ @JsonProperty("lastSeason") @SerialName("lastSeason") val lastSeason: Int? = null,
+ @JsonProperty("date") @SerialName("date") val date: String? = null,
+ @JsonProperty("airedDate") @SerialName("airedDate") val airedDate: String? = null,
)
}
From b1022805bbf993c48ece069fddefc4016fa3e695 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Thu, 2 Jul 2026 15:41:12 -0600
Subject: [PATCH 08/94] Add kotlin-reflect to jvmCommonMain in library (#2989)
---
gradle/libs.versions.toml | 1 +
library/build.gradle.kts | 1 +
2 files changed, 2 insertions(+)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 99f1aca80..d0c8eef94 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -92,6 +92,7 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
junit = { module = "junit:junit", version.ref = "junit" }
junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "junitKtx" }
juniversalchardet = { module = "com.github.albfernandez:juniversalchardet", version.ref = "juniversalchardet" }
+kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinGradlePlugin" }
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlinGradlePlugin" }
kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "kotlinxAtomicfu" }
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
diff --git a/library/build.gradle.kts b/library/build.gradle.kts
index 4b713656e..23351f7d0 100644
--- a/library/build.gradle.kts
+++ b/library/build.gradle.kts
@@ -81,6 +81,7 @@ kotlin {
val jvmCommonMain by creating {
dependsOn(commonMain.get())
dependencies {
+ implementation(libs.kotlin.reflect)
implementation(libs.newpipeextractor)
}
}
From d164c36123841bdb0a980abbc152ba0f577d0a75 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Thu, 2 Jul 2026 15:42:32 -0600
Subject: [PATCH 09/94] [skip ci] Remove completely commented and out of date
ProviderTests file (#2973)
---
.../lagradost/cloudstream3/ProviderTests.kt | 220 ------------------
1 file changed, 220 deletions(-)
delete mode 100644 app/src/test/java/com/lagradost/cloudstream3/ProviderTests.kt
diff --git a/app/src/test/java/com/lagradost/cloudstream3/ProviderTests.kt b/app/src/test/java/com/lagradost/cloudstream3/ProviderTests.kt
deleted file mode 100644
index e09e194ef..000000000
--- a/app/src/test/java/com/lagradost/cloudstream3/ProviderTests.kt
+++ /dev/null
@@ -1,220 +0,0 @@
-package com.lagradost.cloudstream3
-
-// import com.lagradost.cloudstream3.APIHolder.allProviders
-// import com.lagradost.cloudstream3.mvvm.logError
-// import com.lagradost.cloudstream3.utils.Qualities
-// import com.lagradost.cloudstream3.utils.SubtitleHelper
-// import kotlinx.coroutines.runBlocking
-// import org.junit.Assert
-// import org.junit.Test
-
-class ProviderTests {
-// private fun getAllProviders(): List {
-// return allProviders.filter { !it.usesWebView }
-// }
-
-// private suspend fun loadLinks(api: MainAPI, url: String?): Boolean {
-// Assert.assertNotNull("Api ${api.name} has invalid url on episode", url)
-// if (url == null) return true
-// var linksLoaded = 0
-// try {
-// val success = api.loadLinks(url, false, {}) { link ->
-// Assert.assertTrue(
-// "Api ${api.name} returns link with invalid Quality",
-// Qualities.values().map { it.value }.contains(link.quality)
-// )
-// Assert.assertTrue(
-// "Api ${api.name} returns link with invalid url",
-// link.url.length > 4
-// )
-// linksLoaded++
-// }
-// if (success) {
-// return linksLoaded > 0
-// }
-// Assert.assertTrue("Api ${api.name} has returns false on .loadLinks", success)
-// } catch (e: Exception) {
-// if (e.cause is NotImplementedError) {
-// Assert.fail("Provider has not implemented .loadLinks")
-// }
-// logError(e)
-// }
-// return true
-// }
-
-// private suspend fun testSingleProviderApi(api: MainAPI): Boolean {
-// val searchQueries = listOf("over", "iron", "guy")
-// var correctResponses = 0
-// var searchResult: List? = null
-// for (query in searchQueries) {
-// val response = try {
-// api.search(query, 1)
-// } catch (e: Exception) {
-// if (e.cause is NotImplementedError) {
-// Assert.fail("Provider has not implemented .search")
-// }
-// logError(e)
-// null
-// }?.items
-// if (!response.isNullOrEmpty()) {
-// correctResponses++
-// if (searchResult == null) {
-// searchResult = response
-// }
-// }
-// }
-
-// if (correctResponses == 0 || searchResult == null) {
-// System.err.println("Api ${api.name} did not return any valid search responses")
-// return false
-// }
-
-// try {
-// var validResults = false
-// for (result in searchResult) {
-// Assert.assertEquals(
-// "Invalid apiName on response on ${api.name}",
-// result.apiName,
-// api.name
-// )
-// val load = api.load(result.url) ?: continue
-// Assert.assertEquals(
-// "Invalid apiName on load on ${api.name}",
-// load.apiName,
-// result.apiName
-// )
-// Assert.assertTrue(
-// "Api ${api.name} on load does not contain any of the supportedTypes",
-// api.supportedTypes.contains(load.type)
-// )
-// when (load) {
-// is AnimeLoadResponse -> {
-// val gotNoEpisodes =
-// load.episodes.keys.isEmpty() || load.episodes.keys.any { load.episodes[it].isNullOrEmpty() }
-
-// if (gotNoEpisodes) {
-// println("Api ${api.name} got no episodes on ${load.url}")
-// continue
-// }
-
-// val url = (load.episodes[load.episodes.keys.first()])?.first()?.data
-// validResults = loadLinks(api, url)
-// if (!validResults) continue
-// }
-// is MovieLoadResponse -> {
-// val gotNoEpisodes = load.dataUrl.isBlank()
-// if (gotNoEpisodes) {
-// println("Api ${api.name} got no movie on ${load.url}")
-// continue
-// }
-
-// validResults = loadLinks(api, load.dataUrl)
-// if (!validResults) continue
-// }
-// is TvSeriesLoadResponse -> {
-// val gotNoEpisodes = load.episodes.isEmpty()
-// if (gotNoEpisodes) {
-// println("Api ${api.name} got no episodes on ${load.url}")
-// continue
-// }
-
-// validResults = loadLinks(api, load.episodes.first().data)
-// if (!validResults) continue
-// }
-// }
-// break
-// }
-
-// Assert.assertTrue("Api ${api.name} did not load on any}", validResults)
-// } catch (e: Exception) {
-// if (e.cause is NotImplementedError) {
-// Assert.fail("Provider has not implemented .load")
-// }
-// logError(e)
-// return false
-// }
-// return true
-// }
-
-// @Test
-// fun providersExist() {
-// Assert.assertTrue(getAllProviders().isNotEmpty())
-// }
-
-// @Test
-// fun providerCorrectData() {
-// val langTagsIETF = SubtitleHelper.languages.map { it.IETF_tag }
-// Assert.assertFalse("langTagsIETF does not contain any languages", langTagsIETF.isNullOrEmpty())
-// for (api in getAllProviders()) {
-// Assert.assertTrue("Api does not contain a mainUrl", api.mainUrl != "NONE")
-// Assert.assertTrue("Api does not contain a name", api.name != "NONE")
-// Assert.assertTrue(
-// "Api ${api.name} does not contain a valid language code",
-// langTagsIETF.contains(api.lang)
-// )
-// Assert.assertTrue(
-// "Api ${api.name} does not contain any supported types",
-// api.supportedTypes.isNotEmpty()
-// )
-// }
-// }
-
-// @Test
-// fun providerCorrectHomepage() {
-// runBlocking {
-// getAllProviders().amap { api ->
-// if (api.hasMainPage) {
-// try {
-// val homepage = api.getMainPage()
-// when {
-// homepage == null -> {
-// Assert.fail("Homepage provider ${api.name} did not correctly load homepage!")
-// }
-// homepage.items.isEmpty() -> {
-// Assert.fail("Homepage provider ${api.name} does not contain any items!")
-// }
-// homepage.items.any { it.list.isEmpty() } -> {
-// Assert.fail("Homepage provider ${api.name} does not have any items on result!")
-// }
-// }
-// } catch (e: Exception) {
-// if (e.cause is NotImplementedError) {
-// Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented")
-// }
-// logError(e)
-// }
-// }
-// }
-// }
-// }
-
-// // @Test
-// // fun testSingleProvider() {
-// // testSingleProviderApi(ThenosProvider())
-// // }
-
-// @Test
-// suspend fun providerCorrect() {
-// val invalidProvider = ArrayList>()
-// val providers = getAllProviders()
-// providers.amap { api ->
-// try {
-// println("Trying $api")
-// if (testSingleProviderApi(api)) {
-// println("Success $api")
-// } else {
-// System.err.println("Error $api")
-// invalidProvider.add(Pair(api, null))
-// }
-// } catch (e: Exception) {
-// logError(e)
-// invalidProvider.add(Pair(api, e))
-// }
-// }
-
-// println("Invalid providers are: ")
-// for (provider in invalidProvider) {
-// println("${provider.first}")
-// }
-// }
-}
\ No newline at end of file
From faacc1a8938bc8eb307c631ef8336154fe6615b2 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Thu, 2 Jul 2026 15:43:53 -0600
Subject: [PATCH 10/94] [skip ci] Remove `failOnNoDiscoveredTests = false`
(#2985)
Not needed anymore since we actually have tests now.
---
build.gradle.kts | 7 -------
1 file changed, 7 deletions(-)
diff --git a/build.gradle.kts b/build.gradle.kts
index 609a94b3a..f58fb584f 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,10 +8,3 @@ plugins {
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
-
-allprojects {
- // https://docs.gradle.org/current/userguide/upgrading_major_version_9.html#test_task_fails_when_no_tests_are_discovered
- tasks.withType().configureEach {
- failOnNoDiscoveredTests = false
- }
-}
From 982189cdf008627f9f8b7e8f0b714ccbf33b96fb Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Thu, 2 Jul 2026 16:12:28 -0600
Subject: [PATCH 11/94] AniListApi: migrate to kotlinx serialization (#2952)
---
.../syncproviders/providers/AniListApi.kt | 571 ++++++++++--------
1 file changed, 322 insertions(+), 249 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt
index 441eb1bf2..d3e7f22c7 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt
@@ -27,9 +27,10 @@ import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
-import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear
import com.lagradost.cloudstream3.utils.txt
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
import java.net.URLEncoder
import java.util.Locale
@@ -54,7 +55,7 @@ class AniListApi : SyncAPI() {
val token = AuthToken(
accessToken = sanitizer["access_token"]
?: throw ErrorLoadingException("No access token"),
- //refreshToken = sanitizer["refresh_token"],
+ // refreshToken = sanitizer["refresh_token"],
accessTokenLifetime = APIHolder.unixTime + sanitizer["expires_in"]!!.toLong(),
)
return token
@@ -81,7 +82,6 @@ class AniListApi : SyncAPI() {
override fun urlToId(url: String): String? =
url.removePrefix("$mainUrl/anime/").removeSuffix("/")
-
private fun getUrlFromId(id: Int): String {
return "$mainUrl/anime/$id"
}
@@ -103,7 +103,6 @@ class AniListApi : SyncAPI() {
val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1)
?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId")
val season = getSeason(internalId).data.media
-
return SyncAPI.SyncResult(
season.id.toString(),
nextAiring = season.nextAiringEpisode?.let {
@@ -157,14 +156,13 @@ class AniListApi : SyncAPI() {
"youtube" -> listOf("https://www.youtube.com/watch?v=${season.trailer.id}")
else -> null
}
- //TODO REST
+ // TODO REST
)
}
override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? {
val internalId = id.toIntOrNull() ?: return null
val data = getDataAboutId(auth ?: return null, internalId) ?: return null
-
return SyncAPI.SyncStatus(
score = Score.from100(data.score),
watchedEpisodes = data.progress,
@@ -260,24 +258,24 @@ class AniListApi : SyncAPI() {
val data =
mapOf(
"query" to query,
- "variables" to
- mapOf(
- "search" to name,
- "page" to 1,
- "type" to "ANIME"
- ).toJson()
+ "variables" to Variables(
+ search = name,
+ page = 1,
+ type = "ANIME",
+ ).toJson()
)
val res = app.post(
"https://graphql.anilist.co/",
- //headers = mapOf(),
- data = data,//(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars))
+ // headers = mapOf(),
+ data = data, // (if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars))
timeout = 5000 // REASONABLE TIMEOUT
).text.replace("\\", "")
- return res.toKotlinObject()
+ return parseJson(res)
} catch (e: Exception) {
logError(e)
}
+
return null
}
@@ -300,7 +298,7 @@ class AniListApi : SyncAPI() {
.replace(")", "\\)")
})"""
)
- //println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}")
+ // println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}")
val shows = searchShows(name.replace(blackListRegex, ""))
shows?.data?.page?.media?.find {
@@ -458,7 +456,7 @@ class AniListApi : SyncAPI() {
cacheTime = 0,
).text
- return tryParseJson(data) ?: throw ErrorLoadingException("Error parsing $data")
+ return tryParseJson(data) ?: throw ErrorLoadingException("Error parsing $data")
}
}
@@ -506,7 +504,6 @@ class AniListApi : SyncAPI() {
type = AniListStatusType.None,
)
}
-
}
private suspend fun postApi(token: AuthToken, q: String, cache: Boolean = false): String? {
@@ -522,71 +519,84 @@ class AniListApi : SyncAPI() {
q,
"UTF-8"
)
- ), //(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars))
+ ), // (if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars))
timeout = 5 // REASONABLE TIMEOUT
).text.replace("\\/", "/")
}
+ @Serializable
+ data class Variables(
+ @JsonProperty("search") @SerialName("search") val search: String,
+ @JsonProperty("page") @SerialName("page") val page: Int,
+ @JsonProperty("type") @SerialName("type") val type: String,
+ )
+ @Serializable
data class MediaRecommendation(
- @JsonProperty("id") val id: Int,
- @JsonProperty("title") val title: Title?,
- @JsonProperty("idMal") val idMal: Int?,
- @JsonProperty("coverImage") val coverImage: CoverImage?,
- @JsonProperty("averageScore") val averageScore: Int?
+ @JsonProperty("id") @SerialName("id") val id: Int,
+ @JsonProperty("title") @SerialName("title") val title: Title?,
+ @JsonProperty("idMal") @SerialName("idMal") val idMal: Int?,
+ @JsonProperty("coverImage") @SerialName("coverImage") val coverImage: CoverImage?,
+ @JsonProperty("averageScore") @SerialName("averageScore") val averageScore: Int?,
)
+ @Serializable
data class FullAnilistList(
- @JsonProperty("data") val data: Data?
+ @JsonProperty("data") @SerialName("data") val data: Data?,
)
+ @Serializable
data class CompletedAt(
- @JsonProperty("year") val year: Int,
- @JsonProperty("month") val month: Int,
- @JsonProperty("day") val day: Int
+ @JsonProperty("year") @SerialName("year") val year: Int,
+ @JsonProperty("month") @SerialName("month") val month: Int,
+ @JsonProperty("day") @SerialName("day") val day: Int,
)
+ @Serializable
data class StartedAt(
- @JsonProperty("year") val year: String?,
- @JsonProperty("month") val month: String?,
- @JsonProperty("day") val day: String?
+ @JsonProperty("year") @SerialName("year") val year: String?,
+ @JsonProperty("month") @SerialName("month") val month: String?,
+ @JsonProperty("day") @SerialName("day") val day: String?,
)
+ @Serializable
data class Title(
- @JsonProperty("english") val english: String?,
- @JsonProperty("romaji") val romaji: String?
+ @JsonProperty("english") @SerialName("english") val english: String?,
+ @JsonProperty("romaji") @SerialName("romaji") val romaji: String?,
)
+ @Serializable
data class CoverImage(
- @JsonProperty("medium") val medium: String?,
- @JsonProperty("large") val large: String?,
- @JsonProperty("extraLarge") val extraLarge: String?
+ @JsonProperty("medium") @SerialName("medium") val medium: String?,
+ @JsonProperty("large") @SerialName("large") val large: String?,
+ @JsonProperty("extraLarge") @SerialName("extraLarge") val extraLarge: String?,
)
+ @Serializable
data class Media(
- @JsonProperty("id") val id: Int,
- @JsonProperty("idMal") val idMal: Int?,
- @JsonProperty("season") val season: String?,
- @JsonProperty("seasonYear") val seasonYear: Int,
- @JsonProperty("format") val format: String?,
- //@JsonProperty("source") val source: String,
- @JsonProperty("episodes") val episodes: Int,
- @JsonProperty("title") val title: Title,
- @JsonProperty("description") val description: String?,
- @JsonProperty("coverImage") val coverImage: CoverImage,
- @JsonProperty("synonyms") val synonyms: List,
- @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
+ @JsonProperty("id") @SerialName("id") val id: Int,
+ @JsonProperty("idMal") @SerialName("idMal") val idMal: Int?,
+ @JsonProperty("season") @SerialName("season") val season: String?,
+ @JsonProperty("seasonYear") @SerialName("seasonYear") val seasonYear: Int,
+ @JsonProperty("format") @SerialName("format") val format: String?,
+ @JsonProperty("episodes") @SerialName("episodes") val episodes: Int,
+ @JsonProperty("title") @SerialName("title") val title: Title,
+ @JsonProperty("description") @SerialName("description") val description: String?,
+ @JsonProperty("coverImage") @SerialName("coverImage") val coverImage: CoverImage,
+ @JsonProperty("synonyms") @SerialName("synonyms") val synonyms: List,
+ @JsonProperty("nextAiringEpisode") @SerialName("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
)
+ @Serializable
data class Entries(
- @JsonProperty("status") val status: String?,
- @JsonProperty("completedAt") val completedAt: CompletedAt,
- @JsonProperty("startedAt") val startedAt: StartedAt,
- @JsonProperty("updatedAt") val updatedAt: Int,
- @JsonProperty("progress") val progress: Int,
- @JsonProperty("score") val score: Int,
- @JsonProperty("private") val private: Boolean,
- @JsonProperty("media") val media: Media
+ @JsonProperty("status") @SerialName("status") val status: String?,
+ @JsonProperty("completedAt") @SerialName("completedAt") val completedAt: CompletedAt,
+ @JsonProperty("startedAt") @SerialName("startedAt") val startedAt: StartedAt,
+ @JsonProperty("updatedAt") @SerialName("updatedAt") val updatedAt: Int,
+ @JsonProperty("progress") @SerialName("progress") val progress: Int,
+ @JsonProperty("score") @SerialName("score") val score: Int,
+ @JsonProperty("private") @SerialName("private") val private: Boolean,
+ @JsonProperty("media") @SerialName("media") val media: Media,
) {
fun toLibraryItem(): SyncAPI.LibraryItem {
return SyncAPI.LibraryItem(
@@ -613,17 +623,20 @@ class AniListApi : SyncAPI() {
}
}
+ @Serializable
data class Lists(
- @JsonProperty("status") val status: String?,
- @JsonProperty("entries") val entries: List
+ @JsonProperty("status") @SerialName("status") val status: String?,
+ @JsonProperty("entries") @SerialName("entries") val entries: List,
)
+ @Serializable
data class MediaListCollection(
- @JsonProperty("lists") val lists: List
+ @JsonProperty("lists") @SerialName("lists") val lists: List,
)
+ @Serializable
data class Data(
- @JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection
+ @JsonProperty("MediaListCollection") @SerialName("MediaListCollection") val mediaListCollection: MediaListCollection,
)
private suspend fun getAniListAnimeListSmart(auth: AuthData): Array? {
@@ -672,7 +685,6 @@ class AniListApi : SyncAPI() {
private suspend fun getFullAniListList(auth: AuthData): FullAnilistList? {
val userID = auth.user.id
val mediaType = "ANIME"
-
val query = """
query (${'$'}userID: Int = $userID, ${'$'}MEDIA: MediaType = $mediaType) {
MediaListCollection (userId: ${'$'}userID, type: ${'$'}MEDIA) {
@@ -711,33 +723,44 @@ class AniListApi : SyncAPI() {
}
}
}
- }
+ }
"""
val text = postApi(auth.token, query)
- return text?.toKotlinObject()
+ return tryParseJson(text)
}
suspend fun toggleLike(auth: AuthData, id: Int): Boolean {
val q = """mutation (${'$'}animeId: Int = $id) {
- ToggleFavourite (animeId: ${'$'}animeId) {
- anime {
- nodes {
- id
- title {
- romaji
- }
- }
- }
- }
- }"""
+ ToggleFavourite (animeId: ${'$'}animeId) {
+ anime {
+ nodes {
+ id
+ title {
+ romaji
+ }
+ }
+ }
+ }
+ }"""
val data = postApi(auth.token, q)
return data != ""
}
/** Used to query a saved MediaItem on the list to get the id for removal */
- data class MediaListItemRoot(@JsonProperty("data") val data: MediaListItem? = null)
- data class MediaListItem(@JsonProperty("MediaList") val mediaList: MediaListId? = null)
- data class MediaListId(@JsonProperty("id") val id: Long? = null)
+ @Serializable
+ data class MediaListItemRoot(
+ @JsonProperty("data") @SerialName("data") val data: MediaListItem? = null,
+ )
+
+ @Serializable
+ data class MediaListItem(
+ @JsonProperty("MediaList") @SerialName("MediaList") val mediaList: MediaListId? = null,
+ )
+
+ @Serializable
+ data class MediaListId(
+ @JsonProperty("id") @SerialName("id") val id: Long? = null,
+ )
private suspend fun postDataAboutId(
auth: AuthData,
@@ -747,7 +770,6 @@ class AniListApi : SyncAPI() {
progress: Int?
): Boolean {
val userID = auth.user.id
-
val q =
// Delete item if status type is None
if (type == AniListStatusType.None) {
@@ -791,22 +813,22 @@ class AniListApi : SyncAPI() {
private suspend fun getUser(token: AuthToken): AniListUser? {
val q = """
- {
- Viewer {
- id
- name
- avatar {
- large
- }
- favourites {
- anime {
- nodes {
- id
- }
+ {
+ Viewer {
+ id
+ name
+ avatar {
+ large
+ }
+ favourites {
+ anime {
+ nodes {
+ id
}
}
- }
- }"""
+ }
+ }
+ }"""
val data = postApi(token, q)
if (data.isNullOrBlank()) return null
val userData = parseJson(data)
@@ -839,305 +861,356 @@ class AniListApi : SyncAPI() {
return seasons.toList()
}
+ @Serializable
data class SeasonResponse(
- @JsonProperty("data") val data: SeasonData,
+ @JsonProperty("data") @SerialName("data") val data: SeasonData,
)
+ @Serializable
data class SeasonData(
- @JsonProperty("Media") val media: SeasonMedia,
+ @JsonProperty("Media") @SerialName("Media") val media: SeasonMedia,
)
+ @Serializable
+ data class RecommendedMedia(
+ @JsonProperty("id") @SerialName("id") val id: Int?,
+ @JsonProperty("title") @SerialName("title") val title: MediaTitle?,
+ @JsonProperty("coverImage") @SerialName("coverImage") val coverImage: MediaCoverImage?,
+ )
+
+ @Serializable
+ data class CharacterMedia(
+ @JsonProperty("id") @SerialName("id") val id: Int?,
+ @JsonProperty("title") @SerialName("title") val title: MediaTitle?,
+ @JsonProperty("coverImage") @SerialName("coverImage") val coverImage: MediaCoverImage?,
+ )
+
+ @Serializable
data class SeasonMedia(
- @JsonProperty("id") val id: Int?,
- @JsonProperty("title") val title: MediaTitle?,
- @JsonProperty("idMal") val idMal: Int?,
- @JsonProperty("format") val format: String?,
- @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
- @JsonProperty("relations") val relations: SeasonEdges?,
- @JsonProperty("coverImage") val coverImage: MediaCoverImage?,
- @JsonProperty("duration") val duration: Int?,
- @JsonProperty("episodes") val episodes: Int?,
- @JsonProperty("genres") val genres: List?,
- @JsonProperty("synonyms") val synonyms: List?,
- @JsonProperty("averageScore") val averageScore: Int?,
- @JsonProperty("isAdult") val isAdult: Boolean?,
- @JsonProperty("trailer") val trailer: MediaTrailer?,
- @JsonProperty("description") val description: String?,
- @JsonProperty("characters") val characters: CharacterConnection?,
- @JsonProperty("recommendations") val recommendations: RecommendationConnection?,
+ @JsonProperty("id") @SerialName("id") val id: Int?,
+ @JsonProperty("title") @SerialName("title") val title: MediaTitle?,
+ @JsonProperty("idMal") @SerialName("idMal") val idMal: Int?,
+ @JsonProperty("format") @SerialName("format") val format: String?,
+ @JsonProperty("nextAiringEpisode") @SerialName("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
+ @JsonProperty("relations") @SerialName("relations") val relations: SeasonEdges?,
+ @JsonProperty("coverImage") @SerialName("coverImage") val coverImage: MediaCoverImage?,
+ @JsonProperty("duration") @SerialName("duration") val duration: Int?,
+ @JsonProperty("episodes") @SerialName("episodes") val episodes: Int?,
+ @JsonProperty("genres") @SerialName("genres") val genres: List?,
+ @JsonProperty("synonyms") @SerialName("synonyms") val synonyms: List?,
+ @JsonProperty("averageScore") @SerialName("averageScore") val averageScore: Int?,
+ @JsonProperty("isAdult") @SerialName("isAdult") val isAdult: Boolean?,
+ @JsonProperty("trailer") @SerialName("trailer") val trailer: MediaTrailer?,
+ @JsonProperty("description") @SerialName("description") val description: String?,
+ @JsonProperty("characters") @SerialName("characters") val characters: CharacterConnection?,
+ @JsonProperty("recommendations") @SerialName("recommendations") val recommendations: RecommendationConnection?,
)
+ @Serializable
data class RecommendationConnection(
- @JsonProperty("edges") val edges: List = emptyList(),
- @JsonProperty("nodes") val nodes: List = emptyList(),
- //@JsonProperty("pageInfo") val pageInfo: PageInfo,
+ @JsonProperty("edges") @SerialName("edges") val edges: List = emptyList(),
+ @JsonProperty("nodes") @SerialName("nodes") val nodes: List = emptyList(),
)
+ @Serializable
data class RecommendationEdge(
- //@JsonProperty("rating") val rating: Int,
- @JsonProperty("node") val node: Recommendation,
+ @JsonProperty("node") @SerialName("node") val node: Recommendation,
)
+ @Serializable
data class Recommendation(
- val id: Long,
- @JsonProperty("mediaRecommendation") val mediaRecommendation: SeasonMedia?,
+ @JsonProperty("mediaRecommendation") @SerialName("mediaRecommendation") val mediaRecommendation: RecommendedMedia?,
)
+ @Serializable
data class CharacterName(
- @JsonProperty("name") val first: String?,
- @JsonProperty("middle") val middle: String?,
- @JsonProperty("last") val last: String?,
- @JsonProperty("full") val full: String?,
- @JsonProperty("native") val native: String?,
- @JsonProperty("alternative") val alternative: List?,
- @JsonProperty("alternativeSpoiler") val alternativeSpoiler: List?,
- @JsonProperty("userPreferred") val userPreferred: String?,
+ @JsonProperty("name") @SerialName("name") val first: String?,
+ @JsonProperty("middle") @SerialName("middle") val middle: String?,
+ @JsonProperty("last") @SerialName("last") val last: String?,
+ @JsonProperty("full") @SerialName("full") val full: String?,
+ @JsonProperty("native") @SerialName("native") val native: String?,
+ @JsonProperty("alternative") @SerialName("alternative") val alternative: List?,
+ @JsonProperty("alternativeSpoiler") @SerialName("alternativeSpoiler") val alternativeSpoiler: List?,
+ @JsonProperty("userPreferred") @SerialName("userPreferred") val userPreferred: String?,
)
+ @Serializable
data class CharacterImage(
- @JsonProperty("large") val large: String?,
- @JsonProperty("medium") val medium: String?,
+ @JsonProperty("large") @SerialName("large") val large: String?,
+ @JsonProperty("medium") @SerialName("medium") val medium: String?,
)
+ @Serializable
data class Character(
- @JsonProperty("name") val name: CharacterName?,
- @JsonProperty("age") val age: String?,
- @JsonProperty("image") val image: CharacterImage?,
+ @JsonProperty("name") @SerialName("name") val name: CharacterName?,
+ @JsonProperty("age") @SerialName("age") val age: String?,
+ @JsonProperty("image") @SerialName("image") val image: CharacterImage?,
)
+ @Serializable
data class CharacterEdge(
- @JsonProperty("id") val id: Int?,
+ @JsonProperty("id") @SerialName("id") val id: Int?,
/**
- MAIN
- A primary character role in the media
-
- SUPPORTING
- A supporting character role in the media
-
- BACKGROUND
- A background character in the media
+ * MAIN - A primary character role in the media
+ * SUPPORTING - A supporting character role in the media
+ * BACKGROUND - A background character in the media
*/
- @JsonProperty("role") val role: String?,
- @JsonProperty("name") val name: String?,
- @JsonProperty("voiceActors") val voiceActors: List?,
- @JsonProperty("favouriteOrder") val favouriteOrder: Int?,
- @JsonProperty("media") val media: List?,
- @JsonProperty("node") val node: Character?,
+ @JsonProperty("role") @SerialName("role") val role: String?,
+ @JsonProperty("name") @SerialName("name") val name: String?,
+ @JsonProperty("voiceActors") @SerialName("voiceActors") val voiceActors: List?,
+ @JsonProperty("favouriteOrder") @SerialName("favouriteOrder") val favouriteOrder: Int?,
+ @JsonProperty("media") @SerialName("media") val media: List?,
+ @JsonProperty("node") @SerialName("node") val node: Character?,
)
+ @Serializable
data class StaffImage(
- @JsonProperty("large") val large: String?,
- @JsonProperty("medium") val medium: String?,
+ @JsonProperty("large") @SerialName("large") val large: String?,
+ @JsonProperty("medium") @SerialName("medium") val medium: String?,
)
+ @Serializable
data class StaffName(
- @JsonProperty("name") val first: String?,
- @JsonProperty("middle") val middle: String?,
- @JsonProperty("last") val last: String?,
- @JsonProperty("full") val full: String?,
- @JsonProperty("native") val native: String?,
- @JsonProperty("alternative") val alternative: List?,
- @JsonProperty("userPreferred") val userPreferred: String?,
+ @JsonProperty("name") @SerialName("name") val first: String?,
+ @JsonProperty("middle") @SerialName("middle") val middle: String?,
+ @JsonProperty("last") @SerialName("last") val last: String?,
+ @JsonProperty("full") @SerialName("full") val full: String?,
+ @JsonProperty("native") @SerialName("native") val native: String?,
+ @JsonProperty("alternative") @SerialName("alternative") val alternative: List?,
+ @JsonProperty("userPreferred") @SerialName("userPreferred") val userPreferred: String?,
)
+ @Serializable
data class Staff(
- @JsonProperty("image") val image: StaffImage?,
- @JsonProperty("name") val name: StaffName?,
- @JsonProperty("age") val age: Int?,
+ @JsonProperty("image") @SerialName("image") val image: StaffImage?,
+ @JsonProperty("name") @SerialName("name") val name: StaffName?,
+ @JsonProperty("age") @SerialName("age") val age: Int?,
)
+ @Serializable
data class CharacterConnection(
- @JsonProperty("edges") val edges: List?,
- @JsonProperty("nodes") val nodes: List?,
- //@JsonProperty("pageInfo") pageInfo: PageInfo
+ @JsonProperty("edges") @SerialName("edges") val edges: List?,
+ @JsonProperty("nodes") @SerialName("nodes") val nodes: List?,
)
+ @Serializable
data class MediaTrailer(
- @JsonProperty("id") val id: String?,
- @JsonProperty("site") val site: String?,
- @JsonProperty("thumbnail") val thumbnail: String?,
+ @JsonProperty("id") @SerialName("id") val id: String?,
+ @JsonProperty("site") @SerialName("site") val site: String?,
+ @JsonProperty("thumbnail") @SerialName("thumbnail") val thumbnail: String?,
)
+ @Serializable
data class MediaCoverImage(
- @JsonProperty("extraLarge") val extraLarge: String?,
- @JsonProperty("large") val large: String?,
- @JsonProperty("medium") val medium: String?,
- @JsonProperty("color") val color: String?,
+ @JsonProperty("extraLarge") @SerialName("extraLarge") val extraLarge: String?,
+ @JsonProperty("large") @SerialName("large") val large: String?,
+ @JsonProperty("medium") @SerialName("medium") val medium: String?,
+ @JsonProperty("color") @SerialName("color") val color: String?,
)
+ @Serializable
data class SeasonNextAiringEpisode(
- @JsonProperty("episode") val episode: Int?,
- @JsonProperty("timeUntilAiring") val timeUntilAiring: Int?,
+ @JsonProperty("episode") @SerialName("episode") val episode: Int?,
+ @JsonProperty("timeUntilAiring") @SerialName("timeUntilAiring") val timeUntilAiring: Int?,
)
+ @Serializable
data class SeasonEdges(
- @JsonProperty("edges") val edges: List?,
+ @JsonProperty("edges") @SerialName("edges") val edges: List?,
)
+ @Serializable
data class SeasonEdge(
- @JsonProperty("id") val id: Int?,
- @JsonProperty("relationType") val relationType: String?,
- @JsonProperty("node") val node: SeasonNode?,
+ @JsonProperty("id") @SerialName("id") val id: Int?,
+ @JsonProperty("relationType") @SerialName("relationType") val relationType: String?,
+ @JsonProperty("node") @SerialName("node") val node: SeasonNode?,
)
+ @Serializable
data class AniListFavoritesMediaConnection(
- @JsonProperty("nodes") val nodes: List,
+ @JsonProperty("nodes") @SerialName("nodes") val nodes: List,
)
+ @Serializable
data class AniListFavourites(
- @JsonProperty("anime") val anime: AniListFavoritesMediaConnection,
+ @JsonProperty("anime") @SerialName("anime") val anime: AniListFavoritesMediaConnection,
)
+ @Serializable
data class MediaTitle(
- @JsonProperty("romaji") val romaji: String?,
- @JsonProperty("english") val english: String?,
- @JsonProperty("native") val native: String?,
- @JsonProperty("userPreferred") val userPreferred: String?,
+ @JsonProperty("romaji") @SerialName("romaji") val romaji: String?,
+ @JsonProperty("english") @SerialName("english") val english: String?,
+ @JsonProperty("native") @SerialName("native") val native: String?,
+ @JsonProperty("userPreferred") @SerialName("userPreferred") val userPreferred: String?,
)
+ @Serializable
data class SeasonNode(
- @JsonProperty("id") val id: Int,
- @JsonProperty("format") val format: String?,
- @JsonProperty("title") val title: Title?,
- @JsonProperty("idMal") val idMal: Int?,
- @JsonProperty("coverImage") val coverImage: CoverImage?,
- @JsonProperty("averageScore") val averageScore: Int?
-// @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
+ @JsonProperty("id") @SerialName("id") val id: Int,
+ @JsonProperty("format") @SerialName("format") val format: String?,
+ @JsonProperty("title") @SerialName("title") val title: Title?,
+ @JsonProperty("idMal") @SerialName("idMal") val idMal: Int?,
+ @JsonProperty("coverImage") @SerialName("coverImage") val coverImage: CoverImage?,
+ @JsonProperty("averageScore") @SerialName("averageScore") val averageScore: Int?,
)
+ @Serializable
data class AniListAvatar(
- @JsonProperty("large") val large: String?,
+ @JsonProperty("large") @SerialName("large") val large: String?,
)
+ @Serializable
data class AniListViewer(
- @JsonProperty("id") val id: Int,
- @JsonProperty("name") val name: String,
- @JsonProperty("avatar") val avatar: AniListAvatar?,
- @JsonProperty("favourites") val favourites: AniListFavourites?,
+ @JsonProperty("id") @SerialName("id") val id: Int,
+ @JsonProperty("name") @SerialName("name") val name: String,
+ @JsonProperty("avatar") @SerialName("avatar") val avatar: AniListAvatar?,
+ @JsonProperty("favourites") @SerialName("favourites") val favourites: AniListFavourites?,
)
+ @Serializable
data class AniListData(
- @JsonProperty("Viewer") val viewer: AniListViewer?,
+ @JsonProperty("Viewer") @SerialName("Viewer") val viewer: AniListViewer?,
)
+ @Serializable
data class AniListRoot(
- @JsonProperty("data") val data: AniListData?,
+ @JsonProperty("data") @SerialName("data") val data: AniListData?,
)
+ @Serializable
data class AniListUser(
- @JsonProperty("id") val id: Int,
- @JsonProperty("name") val name: String,
- @JsonProperty("picture") val picture: String?,
+ @JsonProperty("id") @SerialName("id") val id: Int,
+ @JsonProperty("name") @SerialName("name") val name: String,
+ @JsonProperty("picture") @SerialName("picture") val picture: String?,
)
+ @Serializable
data class LikeNode(
- @JsonProperty("id") val id: Int?,
- //@JsonProperty("idMal") public int idMal;
+ @JsonProperty("id") @SerialName("id") val id: Int?,
)
+ @Serializable
data class LikePageInfo(
- @JsonProperty("total") val total: Int?,
- @JsonProperty("currentPage") val currentPage: Int?,
- @JsonProperty("lastPage") val lastPage: Int?,
- @JsonProperty("perPage") val perPage: Int?,
- @JsonProperty("hasNextPage") val hasNextPage: Boolean?,
+ @JsonProperty("total") @SerialName("total") val total: Int?,
+ @JsonProperty("currentPage") @SerialName("currentPage") val currentPage: Int?,
+ @JsonProperty("lastPage") @SerialName("lastPage") val lastPage: Int?,
+ @JsonProperty("perPage") @SerialName("perPage") val perPage: Int?,
+ @JsonProperty("hasNextPage") @SerialName("hasNextPage") val hasNextPage: Boolean?,
)
+ @Serializable
data class LikeAnime(
- @JsonProperty("nodes") val nodes: List?,
- @JsonProperty("pageInfo") val pageInfo: LikePageInfo?,
+ @JsonProperty("nodes") @SerialName("nodes") val nodes: List?,
+ @JsonProperty("pageInfo") @SerialName("pageInfo") val pageInfo: LikePageInfo?,
)
+ @Serializable
data class LikeFavourites(
- @JsonProperty("anime") val anime: LikeAnime?,
+ @JsonProperty("anime") @SerialName("anime") val anime: LikeAnime?,
)
+ @Serializable
data class LikeViewer(
- @JsonProperty("favourites") val favourites: LikeFavourites?,
+ @JsonProperty("favourites") @SerialName("favourites") val favourites: LikeFavourites?,
)
+ @Serializable
data class LikeData(
- @JsonProperty("Viewer") val viewer: LikeViewer?,
+ @JsonProperty("Viewer") @SerialName("Viewer") val viewer: LikeViewer?,
)
+ @Serializable
data class LikeRoot(
- @JsonProperty("data") val data: LikeData?,
+ @JsonProperty("data") @SerialName("data") val data: LikeData?,
)
+ @Serializable
data class AniListTitleHolder(
- @JsonProperty("title") val title: Title?,
- @JsonProperty("isFavourite") val isFavourite: Boolean?,
- @JsonProperty("id") val id: Int?,
- @JsonProperty("progress") val progress: Int?,
- @JsonProperty("episodes") val episodes: Int?,
- @JsonProperty("score") val score: Int?,
- @JsonProperty("type") val type: AniListStatusType?,
+ @JsonProperty("title") @SerialName("title") val title: Title?,
+ @JsonProperty("isFavourite") @SerialName("isFavourite") val isFavourite: Boolean?,
+ @JsonProperty("id") @SerialName("id") val id: Int?,
+ @JsonProperty("progress") @SerialName("progress") val progress: Int?,
+ @JsonProperty("episodes") @SerialName("episodes") val episodes: Int?,
+ @JsonProperty("score") @SerialName("score") val score: Int?,
+ @JsonProperty("type") @SerialName("type") val type: AniListStatusType?,
)
+ @Serializable
data class GetDataMediaListEntry(
- @JsonProperty("progress") val progress: Int?,
- @JsonProperty("status") val status: String?,
- @JsonProperty("score") val score: Int?,
+ @JsonProperty("progress") @SerialName("progress") val progress: Int?,
+ @JsonProperty("status") @SerialName("status") val status: String?,
+ @JsonProperty("score") @SerialName("score") val score: Int?,
)
+ @Serializable
data class Nodes(
- @JsonProperty("id") val id: Int?,
- @JsonProperty("mediaRecommendation") val mediaRecommendation: MediaRecommendation?
+ @JsonProperty("id") @SerialName("id") val id: Int?,
+ @JsonProperty("mediaRecommendation") @SerialName("mediaRecommendation") val mediaRecommendation: MediaRecommendation?,
)
+ @Serializable
data class GetDataMedia(
- @JsonProperty("isFavourite") val isFavourite: Boolean?,
- @JsonProperty("episodes") val episodes: Int?,
- @JsonProperty("title") val title: Title?,
- @JsonProperty("mediaListEntry") val mediaListEntry: GetDataMediaListEntry?
+ @JsonProperty("isFavourite") @SerialName("isFavourite") val isFavourite: Boolean?,
+ @JsonProperty("episodes") @SerialName("episodes") val episodes: Int?,
+ @JsonProperty("title") @SerialName("title") val title: Title?,
+ @JsonProperty("mediaListEntry") @SerialName("mediaListEntry") val mediaListEntry: GetDataMediaListEntry?,
)
+ @Serializable
data class Recommendations(
- @JsonProperty("nodes") val nodes: List?
+ @JsonProperty("nodes") @SerialName("nodes") val nodes: List?,
)
+ @Serializable
data class GetDataData(
- @JsonProperty("Media") val media: GetDataMedia?,
+ @JsonProperty("Media") @SerialName("Media") val media: GetDataMedia?,
)
+ @Serializable
data class GetDataRoot(
- @JsonProperty("data") val data: GetDataData?,
+ @JsonProperty("data") @SerialName("data") val data: GetDataData?,
)
+ @Serializable
data class GetSearchTitle(
- @JsonProperty("romaji") val romaji: String?,
+ @JsonProperty("romaji") @SerialName("romaji") val romaji: String?,
)
+ @Serializable
data class TrailerObject(
- @JsonProperty("id") val id: String?,
- @JsonProperty("thumbnail") val thumbnail: String?,
- @JsonProperty("site") val site: String?,
+ @JsonProperty("id") @SerialName("id") val id: String?,
+ @JsonProperty("thumbnail") @SerialName("thumbnail") val thumbnail: String?,
+ @JsonProperty("site") @SerialName("site") val site: String?,
)
+ @Serializable
data class GetSearchMedia(
- @JsonProperty("id") val id: Int,
- @JsonProperty("idMal") val idMal: Int?,
- @JsonProperty("seasonYear") val seasonYear: Int,
- @JsonProperty("title") val title: GetSearchTitle,
- @JsonProperty("startDate") val startDate: StartedAt,
- @JsonProperty("averageScore") val averageScore: Int?,
- @JsonProperty("meanScore") val meanScore: Int?,
- @JsonProperty("bannerImage") val bannerImage: String?,
- @JsonProperty("trailer") val trailer: TrailerObject?,
- @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
- @JsonProperty("recommendations") val recommendations: Recommendations?,
- @JsonProperty("relations") val relations: SeasonEdges?
+ @JsonProperty("id") @SerialName("id") val id: Int,
+ @JsonProperty("idMal") @SerialName("idMal") val idMal: Int?,
+ @JsonProperty("seasonYear") @SerialName("seasonYear") val seasonYear: Int,
+ @JsonProperty("title") @SerialName("title") val title: GetSearchTitle,
+ @JsonProperty("startDate") @SerialName("startDate") val startDate: StartedAt,
+ @JsonProperty("averageScore") @SerialName("averageScore") val averageScore: Int?,
+ @JsonProperty("meanScore") @SerialName("meanScore") val meanScore: Int?,
+ @JsonProperty("bannerImage") @SerialName("bannerImage") val bannerImage: String?,
+ @JsonProperty("trailer") @SerialName("trailer") val trailer: TrailerObject?,
+ @JsonProperty("nextAiringEpisode") @SerialName("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
+ @JsonProperty("recommendations") @SerialName("recommendations") val recommendations: Recommendations?,
+ @JsonProperty("relations") @SerialName("relations") val relations: SeasonEdges?,
)
+ @Serializable
data class GetSearchPage(
- @JsonProperty("Page") val page: GetSearchData?,
+ @JsonProperty("Page") @SerialName("Page") val page: GetSearchData?,
)
+ @Serializable
data class GetSearchData(
- @JsonProperty("media") val media: List?,
+ @JsonProperty("media") @SerialName("media") val media: List?,
)
+ @Serializable
data class GetSearchRoot(
- @JsonProperty("data") val data: GetSearchPage?,
+ @JsonProperty("data") @SerialName("data") val data: GetSearchPage?,
)
-}
\ No newline at end of file
+}
From 5e85c80fd548eb76ba17487e9996e5bcdb3ea509 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Thu, 2 Jul 2026 16:14:40 -0600
Subject: [PATCH 12/94] Replace some JVM specific stuff in JsHunter and
JsUnpacker (#2990)
---
.../kotlin/com/lagradost/cloudstream3/utils/JsHunter.kt | 4 ++--
.../kotlin/com/lagradost/cloudstream3/utils/JsUnpacker.kt | 7 +++----
2 files changed, 5 insertions(+), 6 deletions(-)
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsHunter.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsHunter.kt
index 8aabeb1f1..4de7a4c87 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsHunter.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsHunter.kt
@@ -23,7 +23,7 @@ class JsHunter(private val hunterJS: String) {
*/
fun dehunt(): String? {
try {
- val regex = Regex("""\}\("([^"]+)",[^,]+,\s*"([^"]+)",\s*(\d+),\s*(\d+)""", RegexOption.DOT_MATCHES_ALL)
+ val regex = Regex("""(?s)\}\("([^"]+)",[^,]+,\s*"([^"]+)",\s*(\d+),\s*(\d+)""")
val match = regex.find(hunterJS)
if (match != null && match.groupValues.size == 5) {
val h = match.groupValues[1]
@@ -77,4 +77,4 @@ class JsHunter(private val hunterJS: String) {
}
return result
}
-}
\ No newline at end of file
+}
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsUnpacker.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsUnpacker.kt
index 2dbbfddb0..20256e195 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsUnpacker.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsUnpacker.kt
@@ -26,8 +26,7 @@ class JsUnpacker(packedJS: String?) {
val js = packedJS ?: return null
try {
val match = Regex(
- """\}\s*\('(.*)',\s*(.*?),\s*(\d+),\s*'(.*?)'\.split\('\|'\)""",
- RegexOption.DOT_MATCHES_ALL
+ """(?s)\}\s*\('(.*)',\s*(.*?),\s*(\d+),\s*'(.*?)'\.split\('\|'\)"""
).find(js)
if (match != null && match.groupValues.size == 5) {
val payload = match.groupValues[1].replace("\\'", "'")
@@ -56,7 +55,7 @@ class JsUnpacker(packedJS: String?) {
val x = unbase.unbase(word)
val value = if (x in symtab.indices) symtab[x] else null
if (!value.isNullOrEmpty()) {
- decoded.replace(
+ decoded.setRange(
wordMatch.range.first + replaceOffset,
wordMatch.range.last + 1 + replaceOffset,
value
@@ -150,4 +149,4 @@ class JsUnpacker(packedJS: String?) {
}
}
}
-}
\ No newline at end of file
+}
From b5e2a0eff41856cde86ef1e14e5ff861875eecea Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Thu, 2 Jul 2026 23:43:39 +0000
Subject: [PATCH 13/94] Update user agent
Fixes #2951
---
.../src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
index 086a97ef1..c34eb83f6 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
@@ -89,7 +89,7 @@ annotation class SkipSerializationTest
const val AllLanguagesName = "universal"
const val USER_AGENT =
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36"
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
class ErrorLoadingException(message: String? = null) : Exception(message)
From 4b6bfa8bbd9d3922087595023773653a8e1e6740 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Thu, 2 Jul 2026 17:48:49 -0600
Subject: [PATCH 14/94] Promote AcraApplication deprecations to error level
(#2630)
---
.../lagradost/cloudstream3/AcraApplication.kt | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
index bbe7d97de..b2ef28348 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt
@@ -7,7 +7,7 @@ package com.lagradost.cloudstream3
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"),
- level = DeprecationLevel.WARNING
+ level = DeprecationLevel.ERROR
)
class AcraApplication {
companion object {
@@ -15,14 +15,14 @@ class AcraApplication {
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"),
- level = DeprecationLevel.WARNING
+ level = DeprecationLevel.ERROR
)
val context get() = CloudStreamApp.context
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"),
- level = DeprecationLevel.WARNING
+ level = DeprecationLevel.ERROR
)
fun removeKeys(folder: String): Int? =
CloudStreamApp.removeKeys(folder)
@@ -30,7 +30,7 @@ class AcraApplication {
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"),
- level = DeprecationLevel.WARNING
+ level = DeprecationLevel.ERROR
)
fun setKey(path: String, value: T) =
CloudStreamApp.setKey(path, value)
@@ -38,7 +38,7 @@ class AcraApplication {
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"),
- level = DeprecationLevel.WARNING
+ level = DeprecationLevel.ERROR
)
fun setKey(folder: String, path: String, value: T) =
CloudStreamApp.setKey(folder, path, value)
@@ -46,7 +46,7 @@ class AcraApplication {
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"),
- level = DeprecationLevel.WARNING
+ level = DeprecationLevel.ERROR
)
inline fun getKey(path: String, defVal: T?): T? =
CloudStreamApp.getKey(path, defVal)
@@ -54,7 +54,7 @@ class AcraApplication {
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"),
- level = DeprecationLevel.WARNING
+ level = DeprecationLevel.ERROR
)
inline fun getKey(path: String): T? =
CloudStreamApp.getKey(path)
@@ -62,7 +62,7 @@ class AcraApplication {
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"),
- level = DeprecationLevel.WARNING
+ level = DeprecationLevel.ERROR
)
inline fun getKey(folder: String, path: String): T? =
CloudStreamApp.getKey(folder, path)
@@ -70,7 +70,7 @@ class AcraApplication {
@Deprecated(
message = "AcraApplication is deprecated, use CloudStreamApp instead",
replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"),
- level = DeprecationLevel.WARNING
+ level = DeprecationLevel.ERROR
)
inline fun getKey(folder: String, path: String, defVal: T?): T? =
CloudStreamApp.getKey(folder, path, defVal)
From fd078d75be02fbece4d88f43e4030894a2c9faf8 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Fri, 3 Jul 2026 12:31:18 -0600
Subject: [PATCH 15/94] [skip ci] Add expect/actual for WorkerThread (#2997)
This will later be necessary for other targets as it only exists on JVM platforms. Other targets would just define it as a base annoation that doesn't do anything, allows us to keep thread safety on JVM platforms but still be able to compile for others that don't have it.
---
.../com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt | 5 ++++-
.../kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt | 4 +++-
.../com/lagradost/cloudstream3/utils/Coroutines.jvmCommon.kt | 3 +++
3 files changed, 10 insertions(+), 2 deletions(-)
create mode 100644 library/src/jvmCommonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.jvmCommon.kt
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt
index e13bcf5ec..4cd8eefa6 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/mvvm/ArchComponentExt.kt
@@ -1,8 +1,10 @@
package com.lagradost.cloudstream3.mvvm
+import androidx.annotation.AnyThread
import com.lagradost.api.Log
import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.utils.AppDebug
+import com.lagradost.cloudstream3.utils.WorkerThread
import kotlinx.coroutines.*
import java.io.InterruptedIOException
import java.net.SocketTimeoutException
@@ -232,8 +234,9 @@ fun throwAbleToResource(
}
}
+@AnyThread
suspend fun safeApiCall(
- apiCall: suspend () -> T,
+ @WorkerThread apiCall: suspend () -> T,
): Resource {
return withContext(Dispatchers.IO) {
try {
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt
index d15ea129c..444709f88 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt
@@ -2,7 +2,6 @@ package com.lagradost.cloudstream3.utils
import androidx.annotation.AnyThread
import androidx.annotation.MainThread
-import androidx.annotation.WorkerThread
import com.lagradost.cloudstream3.Prerelease
import com.lagradost.cloudstream3.mvvm.launchSafe
import com.lagradost.cloudstream3.mvvm.logError
@@ -10,6 +9,9 @@ import kotlinx.coroutines.*
@AnyThread
expect fun runOnMainThreadNative(@MainThread work: (() -> Unit))
+
+internal expect annotation class WorkerThread()
+
object Coroutines {
@AnyThread
fun T.main(@MainThread work: suspend ((T) -> Unit)): Job {
diff --git a/library/src/jvmCommonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.jvmCommon.kt b/library/src/jvmCommonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.jvmCommon.kt
new file mode 100644
index 000000000..2ae886f59
--- /dev/null
+++ b/library/src/jvmCommonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.jvmCommon.kt
@@ -0,0 +1,3 @@
+package com.lagradost.cloudstream3.utils
+
+internal actual typealias WorkerThread = androidx.annotation.WorkerThread
From bc270d538c7a72d912836c3b2d5d5286cffcda4b Mon Sep 17 00:00:00 2001
From: Bnyro
Date: Fri, 3 Jul 2026 20:41:43 +0200
Subject: [PATCH 16/94] feat(extractors): add streamcash.to extractor (#2994)
---
.../cloudstream3/extractors/Streamcash.kt | 33 +++++++++++++++++++
.../cloudstream3/utils/ExtractorApi.kt | 2 ++
2 files changed, 35 insertions(+)
create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamcash.kt
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamcash.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamcash.kt
new file mode 100644
index 000000000..2775c3062
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamcash.kt
@@ -0,0 +1,33 @@
+package com.lagradost.cloudstream3.extractors
+
+import com.lagradost.cloudstream3.Prerelease
+import com.lagradost.cloudstream3.SubtitleFile
+import com.lagradost.cloudstream3.utils.ExtractorApi
+import com.lagradost.cloudstream3.utils.ExtractorLink
+import com.lagradost.cloudstream3.utils.ExtractorLinkType
+import com.lagradost.cloudstream3.utils.newExtractorLink
+
+@Prerelease
+open class Streamcash: ExtractorApi() {
+ override val name: String = "Streamcash"
+ override val mainUrl: String = "https://streamcash.to"
+ open val cdnUrl: String = "https://cdn.streamcash.to"
+ override val requiresReferer: Boolean = false
+
+ override suspend fun getUrl(
+ url: String,
+ referer: String?,
+ subtitleCallback: (SubtitleFile) -> Unit,
+ callback: (ExtractorLink) -> Unit
+ ) {
+ val id = url.removeSuffix("/").substringAfterLast("/")
+ callback.invoke(
+ newExtractorLink(
+ name = name,
+ source = name,
+ url = "$cdnUrl/videos/$id/index.m3u8",
+ type = ExtractorLinkType.M3U8
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt
index 7e1b9e9ed..1605138d9 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt
@@ -271,6 +271,7 @@ import com.lagradost.cloudstream3.extractors.VidHidePro5
import com.lagradost.cloudstream3.extractors.VidHidePro6
import com.lagradost.cloudstream3.extractors.VidHideHub
import com.lagradost.cloudstream3.extractors.Ryderjet
+import com.lagradost.cloudstream3.extractors.Streamcash
import com.lagradost.cloudstream3.extractors.VidMoxy
import com.lagradost.cloudstream3.extractors.VidStack
import com.lagradost.cloudstream3.extractors.VideoSeyred
@@ -1090,6 +1091,7 @@ val extractorApis: AtomicMutableList = atomicListOf(
Tantifilm(),
Userload(),
Supervideo(),
+ Streamcash(),
// StreamSB.kt works
// SBPlay(),
From adb57c86b388e1e7203fe4a242f59f96a9036643 Mon Sep 17 00:00:00 2001
From: Bnyro
Date: Fri, 3 Jul 2026 20:43:40 +0200
Subject: [PATCH 17/94] feat(extractors): add firestream.to extractor (#2993)
---
.../cloudstream3/extractors/Firestream.kt | 52 +++++++++++++++++++
.../cloudstream3/utils/ExtractorApi.kt | 4 +-
2 files changed, 55 insertions(+), 1 deletion(-)
create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Firestream.kt
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Firestream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Firestream.kt
new file mode 100644
index 000000000..c8ef5d0ac
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Firestream.kt
@@ -0,0 +1,52 @@
+package com.lagradost.cloudstream3.extractors
+
+import com.lagradost.cloudstream3.Prerelease
+import com.lagradost.cloudstream3.SubtitleFile
+import com.lagradost.cloudstream3.app
+import com.lagradost.cloudstream3.utils.ExtractorApi
+import com.lagradost.cloudstream3.utils.ExtractorLink
+import com.lagradost.cloudstream3.utils.newExtractorLink
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Prerelease
+class Firestream : ExtractorApi() {
+ override val name: String = "Firestream"
+ override val mainUrl: String = "https://firestream.to"
+ override val requiresReferer: Boolean = false
+
+ override suspend fun getUrl(
+ url: String,
+ referer: String?,
+ subtitleCallback: (SubtitleFile) -> Unit,
+ callback: (ExtractorLink) -> Unit
+ ) {
+ val id = url.removeSuffix("/").substringAfterLast("/")
+ val url = getExtractorUrl(id)
+
+ val doc = app.get(url).document
+ val token = doc.selectFirst("script[id=token-blob]")!!.data()
+
+ val videoResponse =
+ app.post("$mainUrl/api/videos/$id/resolve", json = mapOf("blob" to token))
+ .parsed()
+
+ callback.invoke(
+ newExtractorLink(
+ source = name,
+ name = name,
+ url = videoResponse.signedVideoUrl
+ )
+ )
+ }
+
+ override fun getExtractorUrl(id: String): String {
+ return "$mainUrl/e/$id"
+ }
+
+ @Serializable
+ private data class VideoResponse(
+ @SerialName("signedVideoUrl")
+ val signedVideoUrl: String,
+ )
+}
\ No newline at end of file
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt
index 1605138d9..9524d04f6 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt
@@ -80,6 +80,7 @@ import com.lagradost.cloudstream3.extractors.FileMoonIn
import com.lagradost.cloudstream3.extractors.FileMoonSx
import com.lagradost.cloudstream3.extractors.FilemoonV2
import com.lagradost.cloudstream3.extractors.Filesim
+import com.lagradost.cloudstream3.extractors.Firestream
import com.lagradost.cloudstream3.extractors.Multimoviesshg
import com.lagradost.cloudstream3.extractors.FlaswishCom
import com.lagradost.cloudstream3.extractors.Flyfile
@@ -1301,7 +1302,8 @@ val extractorApis: AtomicMutableList = atomicListOf(
GUpload(),
HlsWish(),
ByseQekaho(),
- Flyfile()
+ Flyfile(),
+ Firestream(),
)
From 2cde74de277adbcc2555405bfef05a6c1773c32a Mon Sep 17 00:00:00 2001
From: Bnyro
Date: Fri, 3 Jul 2026 20:47:31 +0200
Subject: [PATCH 18/94] feat(extractors): fix vidara and update list of mirrors
(#2992)
---
.../extractors/{Streamup.kt => Vidara.kt} | 54 ++++++++++++++-----
.../cloudstream3/utils/ExtractorApi.kt | 20 +++++--
2 files changed, 56 insertions(+), 18 deletions(-)
rename library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/{Streamup.kt => Vidara.kt} (61%)
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamup.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidara.kt
similarity index 61%
rename from library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamup.kt
rename to library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidara.kt
index ea85a005e..d74a1ccd6 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamup.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vidara.kt
@@ -8,23 +8,45 @@ import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.newExtractorLink
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
-class Streamix(): Streamup() {
- override val name: String = "Streamix"
- override val mainUrl = "https://streamix.so"
+class Vidavaca : Vidara() {
+ override val mainUrl = "https://vidavaca.net"
}
-class Vidara(): Streamup() {
+class VidaaraxNet : Vidara() {
+ override val mainUrl = "https://vidaarax.net"
+}
+
+class VidaaraxCom : Vidara() {
+ override val mainUrl = "https://vidaarax.com"
+}
+
+class Vidaratem : Vidara() {
+ override val mainUrl = "https://vidaratem.com"
+}
+
+class Vidaraw : Vidara() {
+ override val mainUrl = "https://vidaraw.com"
+}
+
+class Vidarax : Vidara() {
+ override val mainUrl = "https://vidarax.cc"
+}
+
+class Vidaraa : Vidara() {
+ override val mainUrl = "https://vidaraa.cc"
+}
+
+class VidaraSo : Vidara() {
+ override val mainUrl = "https://vidara.so"
+}
+
+open class Vidara : ExtractorApi() {
override val name: String = "Vidara"
override val mainUrl = "https://vidara.to"
- override val apiPath: String = "/api/stream"
-}
-
-open class Streamup() : ExtractorApi() {
- override val name: String = "Streamup"
- override val mainUrl: String = "https://strmup.to"
override val requiresReferer: Boolean = false
- open val apiPath: String = "/ajax/stream"
override suspend fun getUrl(
url: String,
@@ -33,8 +55,9 @@ open class Streamup() : ExtractorApi() {
callback: (ExtractorLink) -> Unit
) {
val fileCode = url.substringAfterLast("/")
- val fileInfo = app.get("$mainUrl$apiPath?filecode=$fileCode")
- .parsed()
+ val fileInfo =
+ app.post("$mainUrl/api/stream", json = mapOf("filecode" to fileCode, "device" to "web"))
+ .parsed()
callback.invoke(
newExtractorLink(
@@ -52,18 +75,21 @@ open class Streamup() : ExtractorApi() {
}
}
+ @Serializable
private data class StreamUpFileInfo(
val title: String,
val thumbnail: String,
+ @SerialName("streaming_url")
@JsonProperty("streaming_url")
val streamingUrl: String,
val subtitles: List?
)
+ @Serializable
private data class StreamUpSubtitle(
+ @SerialName("file_path")
@JsonProperty("file_path")
val filePath: String,
- @JsonProperty("language")
val language: String,
)
}
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt
index 9524d04f6..7ab533ece 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt
@@ -229,12 +229,10 @@ import com.lagradost.cloudstream3.extractors.StreamWishExtractor
import com.lagradost.cloudstream3.extractors.StreamhideCom
import com.lagradost.cloudstream3.extractors.StreamhideTo
import com.lagradost.cloudstream3.extractors.Streamhub2
-import com.lagradost.cloudstream3.extractors.Streamix
import com.lagradost.cloudstream3.extractors.Streamlare
import com.lagradost.cloudstream3.extractors.StreamoUpload
import com.lagradost.cloudstream3.extractors.Streamplay
import com.lagradost.cloudstream3.extractors.Streamsss
-import com.lagradost.cloudstream3.extractors.Streamup
import com.lagradost.cloudstream3.extractors.Streamwish2
import com.lagradost.cloudstream3.extractors.Strwish
import com.lagradost.cloudstream3.extractors.Strwish2
@@ -289,7 +287,15 @@ import com.lagradost.cloudstream3.extractors.Vidoza
import com.lagradost.cloudstream3.extractors.VinovoSi
import com.lagradost.cloudstream3.extractors.VinovoTo
import com.lagradost.cloudstream3.extractors.VidNest
+import com.lagradost.cloudstream3.extractors.VidaaraxCom
+import com.lagradost.cloudstream3.extractors.VidaaraxNet
import com.lagradost.cloudstream3.extractors.Vidara
+import com.lagradost.cloudstream3.extractors.VidaraSo
+import com.lagradost.cloudstream3.extractors.Vidaraa
+import com.lagradost.cloudstream3.extractors.Vidaratem
+import com.lagradost.cloudstream3.extractors.Vidaraw
+import com.lagradost.cloudstream3.extractors.Vidarax
+import com.lagradost.cloudstream3.extractors.Vidavaca
import com.lagradost.cloudstream3.extractors.Vide0Net
import com.lagradost.cloudstream3.extractors.Vidsonic
import com.lagradost.cloudstream3.extractors.VkExtractor
@@ -1163,9 +1169,15 @@ val extractorApis: AtomicMutableList = atomicListOf(
MoviehabNet(),
Jeniusplay(),
StreamoUpload(),
- Streamup(),
- Streamix(),
Vidara(),
+ Vidavaca(),
+ Vidaraa(),
+ Vidaraw(),
+ Vidarax(),
+ VidaraSo(),
+ Vidaratem(),
+ VidaaraxCom(),
+ VidaaraxNet(),
GamoVideo(),
Gdriveplayerapi(),
From 771a201d7609092d2340af96c1ef33bf810060e1 Mon Sep 17 00:00:00 2001
From: danzybae
Date: Tue, 7 Jul 2026 01:24:48 +0700
Subject: [PATCH 19/94] Fix regex to match quality case insensitively (#3002)
---
.../com/lagradost/cloudstream3/extractors/DoodExtractor.kt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt
index bce017276..099406f16 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt
@@ -112,7 +112,7 @@ open class DoodLaExtractor : ExtractorApi() {
val response0 = req.text
val md5 = host + (Regex("/pass_md5/[^']*").find(response0)?.value ?: return)
val trueUrl = app.get(md5, referer = req.url).text + createHashTable() + "?token=" + md5.substringAfterLast("/")
- val quality = Regex("\\d{3,4}p")
+ val quality = Regex("\\d{3,4}[pP]")
.find(response0.substringAfter("").substringBefore(""))
?.groupValues
?.getOrNull(0)
From 5bcac072e10c4bbe98e59e485216a6bbc99c2d43 Mon Sep 17 00:00:00 2001
From: Bnyro
Date: Mon, 6 Jul 2026 22:04:38 +0200
Subject: [PATCH 20/94] fix(extractors) fix gupload extractor (#3007)
---
.../kotlin/com/lagradost/cloudstream3/extractors/GUpload.kt | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GUpload.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GUpload.kt
index 7daaa55cc..1203b2167 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GUpload.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/GUpload.kt
@@ -23,16 +23,14 @@ open class GUpload: ExtractorApi() {
) {
val response = app.get(url, referer = referer).text
- val playerConfigEncoded = response.substringAfter("decodePayload('").substringBefore("');")
- val playerConfigString = base64Decode(playerConfigEncoded).substringAfter("|")
-
+ val playerConfigString = response.substringAfter("const config = ").substringBefore(";")
val playerConfig = AppUtils.parseJson(playerConfigString)
callback.invoke(
newExtractorLink(
source = name,
name = name,
- url = playerConfig.videoUrl.replace("\\", ""),
+ url = playerConfig.videoUrl,
) {
Regex("/(\\d+p)\\.").find(playerConfig.videoUrl)?.groupValues?.get(1)?.let {
quality = getQualityFromName(it)
From 8828ee1718f7d2f442cf085352cdfa719504c7cb Mon Sep 17 00:00:00 2001
From: Bnyro
Date: Mon, 6 Jul 2026 22:05:23 +0200
Subject: [PATCH 21/94] feat(extractors): add vids.st extractor (#3008)
---
.../lagradost/cloudstream3/extractors/Vids.kt | 37 +++++++++++++++++++
.../cloudstream3/utils/ExtractorApi.kt | 2 +
2 files changed, 39 insertions(+)
create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vids.kt
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vids.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vids.kt
new file mode 100644
index 000000000..33fcdfd3a
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vids.kt
@@ -0,0 +1,37 @@
+package com.lagradost.cloudstream3.extractors
+
+import com.lagradost.cloudstream3.Prerelease
+import com.lagradost.cloudstream3.SubtitleFile
+import com.lagradost.cloudstream3.app
+import com.lagradost.cloudstream3.utils.ExtractorApi
+import com.lagradost.cloudstream3.utils.ExtractorLink
+import com.lagradost.cloudstream3.utils.newExtractorLink
+
+@Prerelease
+open class Vids : ExtractorApi() {
+ override val name: String = "Vids"
+ override val mainUrl: String = " https://vids.st"
+ override val requiresReferer: Boolean = false
+
+ private val streamUrlRegex = Regex("""const url = "(.*?)";""")
+
+ override suspend fun getUrl(
+ url: String,
+ referer: String?,
+ subtitleCallback: (SubtitleFile) -> Unit,
+ callback: (ExtractorLink) -> Unit
+ ) {
+ val doc = app.get(url).text
+ val streamUrlMatch =
+ streamUrlRegex.find(doc) ?: throw RuntimeException("vids: failed to find stream link")
+ val streamUrl = streamUrlMatch.groupValues[1].replace("\\", "")
+
+ callback.invoke(
+ newExtractorLink(
+ source = name,
+ name = name,
+ url = streamUrl
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt
index 7ab533ece..8ef391050 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt
@@ -297,6 +297,7 @@ import com.lagradost.cloudstream3.extractors.Vidaraw
import com.lagradost.cloudstream3.extractors.Vidarax
import com.lagradost.cloudstream3.extractors.Vidavaca
import com.lagradost.cloudstream3.extractors.Vide0Net
+import com.lagradost.cloudstream3.extractors.Vids
import com.lagradost.cloudstream3.extractors.Vidsonic
import com.lagradost.cloudstream3.extractors.VkExtractor
import com.lagradost.cloudstream3.extractors.Voe
@@ -1316,6 +1317,7 @@ val extractorApis: AtomicMutableList = atomicListOf(
ByseQekaho(),
Flyfile(),
Firestream(),
+ Vids(),
)
From bfb85ca5c96e33b1e068fa76ccc89bfac3ab6297 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Mon, 6 Jul 2026 14:17:29 -0600
Subject: [PATCH 22/94] [skip ci] SerializationClassTester: skip abstract
classes (#2960)
Necessary for some such as LibrarySearchResponse and DownloadCached. Using the skip annotation causes it to also skip child classes which we don't want so just skip abstract classes since Instancio can't create an instance of an abstract class: "It is an abstract class and no subtype was provided".
---
.../com/lagradost/cloudstream3/SerializationClassTester.kt | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt
index de6802635..84ef1fee0 100644
--- a/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt
+++ b/app/src/androidTest/java/com/lagradost/cloudstream3/SerializationClassTester.kt
@@ -136,9 +136,9 @@ class SerializationClassTester {
runCatching { Class.forName(it).kotlin }.getOrNull()
}.filter { kClass ->
// Not possible to use .hasAnnotation() on newer Android versions.
- kClass.java.annotations.any {
- it is Serializable
- } && kClass.java.annotations.none { it is SkipSerializationTest }
+ kClass.java.annotations.any { it is Serializable }
+ && kClass.java.annotations.none { it is SkipSerializationTest }
+ && !kClass.isAbstract
}
}
From e8c005dfeb8ead311cd2c8039b1e6b6ca0b7823a Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Tue, 7 Jul 2026 14:05:07 +0000
Subject: [PATCH 23/94] [skip ci] Fix: Leading space in Vids (#3022)
---
.../kotlin/com/lagradost/cloudstream3/extractors/Vids.kt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vids.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vids.kt
index 33fcdfd3a..1312570d3 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vids.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vids.kt
@@ -10,7 +10,7 @@ import com.lagradost.cloudstream3.utils.newExtractorLink
@Prerelease
open class Vids : ExtractorApi() {
override val name: String = "Vids"
- override val mainUrl: String = " https://vids.st"
+ override val mainUrl: String = "https://vids.st"
override val requiresReferer: Boolean = false
private val streamUrlRegex = Regex("""const url = "(.*?)";""")
From 55b5017068efd32a11088d8dd6f7af9353a6854a Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Tue, 7 Jul 2026 14:06:47 +0000
Subject: [PATCH 24/94] [skip ci] Fix: Sync episode setting, Closes #3014
(#3021)
---
.../com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
index 09b6db50f..d9e9cbded 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
@@ -1698,8 +1698,10 @@ class GeneratorPlayer : FullScreenPlayer() {
if (settingsManager.getBoolean(
ctx.getString(R.string.episode_sync_enabled_key), true
)
- ) maxEpisodeSet = meta.episode
- sync.modifyMaxEpisode(meta.totalEpisodeIndex ?: meta.episode)
+ ) {
+ maxEpisodeSet = meta.episode
+ sync.modifyMaxEpisode(meta.totalEpisodeIndex ?: meta.episode)
+ }
}
}
From 94ce0d438548df4e916de5c869155705b1a5d7c3 Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Tue, 7 Jul 2026 14:09:34 +0000
Subject: [PATCH 25/94] Fix: Repo dismiss (#3020)
---
.../cloudstream3/plugins/RepositoryManager.kt | 12 ++--
.../settings/extensions/ExtensionsFragment.kt | 70 ++++++++++---------
2 files changed, 42 insertions(+), 40 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
index 523798b6e..5868146c7 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
@@ -141,13 +141,11 @@ object RepositoryManager {
}
} else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) {
safeAsync {
- app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false).let { it2 ->
- it2.headers["Location"]?.let { url ->
- if (url.startsWith("https://cutt.ly/404")) return@safeAsync null
- if (url.removeSuffix("/") == "https://cutt.ly") return@safeAsync null
- return@safeAsync url
- }
- }
+ val response = app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false)
+ val url = response.headers["Location"] ?: return@safeAsync null
+ if (url.startsWith("https://cutt.ly/404")) return@safeAsync null
+ if (url.removeSuffix("/") == "https://cutt.ly") return@safeAsync null
+ return@safeAsync url
}
} else null
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt
index 9b3f381d0..ea0d5b2bd 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt
@@ -275,42 +275,46 @@ class ExtensionsFragment : BaseFragment(
}
}
- binding.applyBtt.setOnClickListener secondListener@{
+ binding.applyBtt.setOnClickListener secondListener@{
val name = binding.repoNameInput.text?.toString()
val urlInput = binding.repoUrlInput.text?.toString()
- ioSafe {
- val url = urlInput?.let { it1 -> RepositoryManager.parseRepoUrl(it1) }
- if (url.isNullOrBlank()) {
- main {
- showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT)
- }
- } else {
- val repository = RepositoryManager.parseRepository(url)
-
- // Exit if wrong repository
- if (repository == null) {
- showToast(R.string.no_repository_found_error, Toast.LENGTH_LONG)
- return@ioSafe
- }
-
- val fixedName = if (!name.isNullOrBlank()) name
- else repository.name
- val newRepo = RepositoryData(repository.iconUrl, fixedName, url)
- RepositoryManager.addRepository(newRepo)
- extensionViewModel.loadStats()
- extensionViewModel.loadRepositories()
-
- val plugins = RepositoryManager.getRepoPlugins(newRepo)
- if (plugins.isNullOrEmpty()) {
- showToast(R.string.no_plugins_found_error, Toast.LENGTH_LONG)
- } else {
- this@ExtensionsFragment.activity?.addRepositoryDialog(
- newRepo
- )
- }
- }
+ if (urlInput.isNullOrEmpty()) {
+ showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT)
+ return@secondListener
+ }
+ ioSafe {
+ val url = RepositoryManager.parseRepoUrl(urlInput)
+ if (url.isNullOrBlank()) {
+ showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT)
+ return@ioSafe
+ }
+ val repository = RepositoryManager.parseRepository(url)
+
+ // Exit if wrong repository
+ if (repository == null) {
+ showToast(R.string.no_repository_found_error, Toast.LENGTH_LONG)
+ return@ioSafe
+ }
+
+ val fixedName = if (!name.isNullOrBlank()) name
+ else repository.name
+ val newRepo = RepositoryData(repository.iconUrl, fixedName, url)
+ RepositoryManager.addRepository(newRepo)
+ extensionViewModel.loadStats()
+ extensionViewModel.loadRepositories()
+
+ dialog.dismissSafe(activity) // Only dismiss if the repo was added
+
+ val plugins = RepositoryManager.getRepoPlugins(newRepo)
+ if (plugins.isNullOrEmpty()) {
+ showToast(R.string.no_plugins_found_error, Toast.LENGTH_LONG)
+ return@ioSafe
+ }
+
+ this@ExtensionsFragment.activity?.addRepositoryDialog(
+ newRepo
+ )
}
- dialog.dismissSafe(activity)
}
binding.cancelBtt.setOnClickListener {
dialog.dismissSafe(activity)
From 5bae7d35d8199eff2533001dfffb89a52f35035e Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Tue, 7 Jul 2026 14:17:39 +0000
Subject: [PATCH 26/94] Fix: Subtitle ordering on names (#3012)
---
.../com/lagradost/cloudstream3/utils/AppContextUtils.kt | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt
index 65519c0f1..c0be85a3f 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt
@@ -365,8 +365,14 @@ object AppContextUtils {
}
}
+ /** Sort subtitles by names */
fun sortSubs(subs: Set): List {
- return subs.sortedBy { it.name }
+ // Be aware, sorting by "$originalName $nameSuffix" causes "a (b) 1" < "a 1",
+ // where "originalName then nameSuffix" preserves "a 1" < "a (b) 1", because we do not compare '(' and '1'.
+ return subs
+ .sortedWith(
+ compareBy { subtitle: SubtitleData -> subtitle.originalName }
+ .thenBy { subtitle: SubtitleData -> subtitle.nameSuffix })
}
fun Context.getApiSettings(): HashSet {
From 894a18a831bdc554f527c9444d4876bbc21633a5 Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Tue, 7 Jul 2026 18:34:00 +0000
Subject: [PATCH 27/94] Feat: Loading apply button (#3025)
---
.../cloudstream3/ui/account/AccountHelper.kt | 70 +++++++++-------
.../cloudstream3/ui/player/GeneratorPlayer.kt | 83 +++++++++++--------
.../ui/settings/SettingsAccount.kt | 5 ++
.../settings/extensions/ExtensionsFragment.kt | 70 +++++++++-------
.../lagradost/cloudstream3/utils/UIHelper.kt | 36 ++++++++
5 files changed, 166 insertions(+), 98 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt
index 1d6b41e5b..7725bad91 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt
@@ -37,8 +37,10 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper
import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
+import com.lagradost.cloudstream3.utils.UIHelper.hideProgress
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod
+import com.lagradost.cloudstream3.utils.UIHelper.showProgress
object AccountHelper {
fun showAccountEditDialog(
@@ -164,7 +166,7 @@ object AccountHelper {
canSetPin = true
- binding.editProfilePhotoButton.setOnClickListener({
+ binding.editProfilePhotoButton.setOnClickListener {
val bottomSheetDialog = BottomSheetDialog(context)
val sheetBinding = BottomInputDialogBinding.inflate(LayoutInflater.from(context))
bottomSheetDialog.setContentView(sheetBinding.root)
@@ -174,42 +176,46 @@ object AccountHelper {
text1.text = context.getString(R.string.edit_profile_image_title)
nginxTextInput.hint = context.getString(R.string.edit_profile_image_hint)
- applyBtt.setOnClickListener({
+ applyBtt.setOnClickListener {
val url = sheetBinding.nginxTextInput.text.toString()
- if (url.isNotEmpty()) {
- val imageLoader = ImageLoader(context)
- val request = ImageRequest.Builder(context)
- .data(url)
- .allowHardware(false)
- .listener(
- onSuccess = { _, _ ->
- currentEditAccount = currentEditAccount.copy(customImage = url)
- binding.accountImage.loadImage(url)
- showToast(
- R.string.edit_profile_image_success,
- Toast.LENGTH_SHORT
- )
- bottomSheetDialog.dismiss()
- },
- onError = { _, _ ->
- showToast(
- R.string.edit_profile_image_error_invalid,
- Toast.LENGTH_SHORT
- )
- }
- )
- .build()
- imageLoader.enqueue(request)
- } else {
+ if (url.isEmpty()) {
showToast(R.string.edit_profile_image_error_empty, Toast.LENGTH_SHORT)
+ return@setOnClickListener
}
+ applyBtt.showProgress()
+ val imageLoader = ImageLoader(context)
+ val request = ImageRequest.Builder(context)
+ .data(url)
+ .allowHardware(false)
+ .listener(
+ onSuccess = { _, _ ->
+ currentEditAccount = currentEditAccount.copy(customImage = url)
+ binding.accountImage.loadImage(url)
+ showToast(
+ R.string.edit_profile_image_success,
+ Toast.LENGTH_SHORT
+ )
+ bottomSheetDialog.dismissSafe()
+ },
+ onError = { _, _ ->
+ showToast(
+ R.string.edit_profile_image_error_invalid,
+ Toast.LENGTH_SHORT
+ )
+ applyBtt.hideProgress()
+ },
+ onCancel = {
+ applyBtt.hideProgress()
+ }
+ )
+ .build()
+ imageLoader.enqueue(request)
+ }
+ sheetBinding.cancelBtt.setOnClickListener {
bottomSheetDialog.dismissSafe()
- })
- sheetBinding.cancelBtt.setOnClickListener({
- bottomSheetDialog.dismissSafe()
- })
+ }
}
- })
+ }
}
fun showPinInputDialog(
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
index d9e9cbded..b64355e23 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
@@ -117,8 +117,10 @@ import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding
+import com.lagradost.cloudstream3.utils.UIHelper.hideProgress
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
+import com.lagradost.cloudstream3.utils.UIHelper.showProgress
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl
import com.lagradost.cloudstream3.utils.setText
@@ -790,47 +792,58 @@ class GeneratorPlayer : FullScreenPlayer() {
}
binding.applyBtt.setOnClickListener {
- currentSubtitle?.let { currentSubtitle ->
- providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }?.let { api ->
- ioSafe {
- when (val apiResource =
- Resource.fromResult(api.resource(currentSubtitle))) {
- is Resource.Success -> {
- val subtitles = apiResource.value.getSubtitles().map { resource ->
- SubtitleData(
- originalName = resource.name ?: getName(
- currentSubtitle,
- true
- ),
- nameSuffix = "",
- url = resource.url,
- origin = resource.origin,
- mimeType = resource.url.toSubtitleMimeType(),
- headers = currentSubtitle.headers,
- languageCode = currentSubtitle.lang
- )
- }
- if (subtitles.isEmpty()) {
- showToast(R.string.no_subtitles)
- return@ioSafe
- }
- runOnMainThread {
- addAndSelectSubtitles(*subtitles.toTypedArray())
- }
- }
+ val currentSubtitle = currentSubtitle
+ if (currentSubtitle == null) {
+ dialog.dismissSafe()
+ return@setOnClickListener
+ }
- is Resource.Failure -> {
- showToast(apiResource.errorString)
- }
+ val api = providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }
+ if (api == null) {
+ dialog.dismissSafe()
+ return@setOnClickListener
+ }
- is Resource.Loading -> {
- // not possible
- }
+ binding.applyBtt.showProgress()
+ ioSafe {
+ val apiResource =
+ Resource.fromResult(api.resource(currentSubtitle))
+ binding.applyBtt.hideProgress()
+ when (apiResource) {
+ is Resource.Success -> {
+ val subtitles = apiResource.value.getSubtitles().map { resource ->
+ SubtitleData(
+ originalName = resource.name ?: getName(
+ currentSubtitle,
+ true
+ ),
+ nameSuffix = "",
+ url = resource.url,
+ origin = resource.origin,
+ mimeType = resource.url.toSubtitleMimeType(),
+ headers = currentSubtitle.headers,
+ languageCode = currentSubtitle.lang
+ )
}
+ if (subtitles.isEmpty()) {
+ showToast(R.string.no_subtitles)
+ return@ioSafe
+ }
+ dialog.dismissSafe()
+ runOnMainThread {
+ addAndSelectSubtitles(*subtitles.toTypedArray())
+ }
+ }
+
+ is Resource.Failure -> {
+ showToast(apiResource.errorString)
+ }
+
+ is Resource.Loading -> {
+ // not possible
}
}
}
- dialog.dismissSafe()
}
dialog.setOnDismissListener {
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt
index 8d96a6b14..75ba55674 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt
@@ -65,6 +65,8 @@ import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogTe
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
+import com.lagradost.cloudstream3.utils.UIHelper.hideProgress
+import com.lagradost.cloudstream3.utils.UIHelper.showProgress
import com.lagradost.cloudstream3.utils.setText
import com.lagradost.cloudstream3.utils.txt
import qrcode.QRCode
@@ -348,6 +350,7 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback {
email = if (req.email) binding.loginEmailInput.text?.toString() else null,
server = if (req.server) binding.loginServerInput.text?.toString() else null,
)
+ binding.applyBtt.showProgress()
ioSafe {
try {
if (api.login(loginData)) {
@@ -377,6 +380,8 @@ class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback {
api.name
)
)
+ } finally {
+ binding.applyBtt.hideProgress()
}
}
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt
index ea0d5b2bd..107227497 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt
@@ -39,6 +39,8 @@ import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
+import com.lagradost.cloudstream3.utils.UIHelper.hideProgress
+import com.lagradost.cloudstream3.utils.UIHelper.showProgress
import com.lagradost.cloudstream3.utils.setText
class ExtensionsFragment : BaseFragment(
@@ -275,45 +277,50 @@ class ExtensionsFragment : BaseFragment(
}
}
- binding.applyBtt.setOnClickListener secondListener@{
+ binding.applyBtt.setOnClickListener secondListener@{
val name = binding.repoNameInput.text?.toString()
val urlInput = binding.repoUrlInput.text?.toString()
if (urlInput.isNullOrEmpty()) {
showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT)
return@secondListener
}
+ binding.applyBtt.showProgress()
ioSafe {
- val url = RepositoryManager.parseRepoUrl(urlInput)
- if (url.isNullOrBlank()) {
- showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT)
- return@ioSafe
+ try {
+ val url = RepositoryManager.parseRepoUrl(urlInput)
+ if (url.isNullOrBlank()) {
+ showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT)
+ return@ioSafe
+ }
+ val repository = RepositoryManager.parseRepository(url)
+
+ // Exit if wrong repository
+ if (repository == null) {
+ showToast(R.string.no_repository_found_error, Toast.LENGTH_LONG)
+ return@ioSafe
+ }
+
+ val fixedName = if (!name.isNullOrBlank()) name
+ else repository.name
+ val newRepo = RepositoryData(repository.iconUrl, fixedName, url)
+ RepositoryManager.addRepository(newRepo)
+ extensionViewModel.loadStats()
+ extensionViewModel.loadRepositories()
+
+ dialog.dismissSafe(activity) // Only dismiss if the repo was added
+
+ val plugins = RepositoryManager.getRepoPlugins(newRepo)
+ if (plugins.isNullOrEmpty()) {
+ showToast(R.string.no_plugins_found_error, Toast.LENGTH_LONG)
+ return@ioSafe
+ }
+
+ this@ExtensionsFragment.activity?.addRepositoryDialog(
+ newRepo
+ )
+ } finally {
+ binding.applyBtt.hideProgress()
}
- val repository = RepositoryManager.parseRepository(url)
-
- // Exit if wrong repository
- if (repository == null) {
- showToast(R.string.no_repository_found_error, Toast.LENGTH_LONG)
- return@ioSafe
- }
-
- val fixedName = if (!name.isNullOrBlank()) name
- else repository.name
- val newRepo = RepositoryData(repository.iconUrl, fixedName, url)
- RepositoryManager.addRepository(newRepo)
- extensionViewModel.loadStats()
- extensionViewModel.loadRepositories()
-
- dialog.dismissSafe(activity) // Only dismiss if the repo was added
-
- val plugins = RepositoryManager.getRepoPlugins(newRepo)
- if (plugins.isNullOrEmpty()) {
- showToast(R.string.no_plugins_found_error, Toast.LENGTH_LONG)
- return@ioSafe
- }
-
- this@ExtensionsFragment.activity?.addRepositoryDialog(
- newRepo
- )
}
}
binding.cancelBtt.setOnClickListener {
@@ -321,6 +328,7 @@ class ExtensionsFragment : BaseFragment(
}
}
+
val isTv = isLayout(TV)
binding.apply {
addRepoButton.isGone = isTv
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt
index c12674816..69f22e8ef 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt
@@ -65,9 +65,12 @@ import androidx.navigation.fragment.NavHostFragment
import androidx.palette.graphics.Palette
import androidx.preference.PreferenceManager
import com.google.android.material.appbar.AppBarLayout
+import com.google.android.material.button.MaterialButton
import com.google.android.material.chip.Chip
import com.google.android.material.chip.ChipDrawable
import com.google.android.material.chip.ChipGroup
+import com.google.android.material.progressindicator.CircularProgressIndicatorSpec
+import com.google.android.material.progressindicator.IndeterminateDrawable
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
import com.lagradost.cloudstream3.CommonActivity.activity
import com.lagradost.cloudstream3.CommonActivity.showToast
@@ -583,6 +586,39 @@ object UIHelper {
}
}
+ /**
+ * Source: https://stackoverflow.com/questions/70954321/circular-progress-indicator-inside-buttons-android-material-design
+ *
+ * Shows indeterminate progress bar on this button in place of where icon would be.
+ * By default the tint of progress bar is the same as iconTint.
+ *
+ * @param tintColor (@ColorInt Int) Sets custom progress bar tint color.
+ */
+ fun MaterialButton.showProgress(@ColorInt tintColor: Int = this.iconTint.defaultColor) =
+ // Use runOnMainThreadNative to allow process on io threads, to make the code a bit cleaner
+ runOnMainThreadNative {
+ val spec = CircularProgressIndicatorSpec(
+ context, null, 0,
+ com.google.android.material.R.style.Widget_Material3_CircularProgressIndicator_ExtraSmall
+ )
+
+ spec.indicatorColors = intArrayOf(tintColor)
+
+ val progressIndicatorDrawable =
+ IndeterminateDrawable.createCircularDrawable(context, spec)
+
+ this.icon = progressIndicatorDrawable
+ if (this.getTag(R.id.text1) == null)
+ this.setTag(R.id.text1, this.text)
+ this.text = ""
+ }
+
+ fun MaterialButton.hideProgress() =
+ runOnMainThreadNative {
+ this.text = this.getTag(R.id.text1) as? String
+ this.icon = null
+ }
+
/**id, stringRes */
@SuppressLint("RestrictedApi")
fun View.popupMenuNoIcons(
From 678726e9eeebb868edaa3f6ac469362deaff8216 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Wed, 8 Jul 2026 08:17:17 -0600
Subject: [PATCH 28/94] GogoHelper: use more clear name for private method
(#3028)
---
.../lagradost/cloudstream3/extractors/helper/GogoHelper.kt | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/GogoHelper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/GogoHelper.kt
index 7bbe403f8..477c965ce 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/GogoHelper.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/GogoHelper.kt
@@ -28,7 +28,7 @@ object GogoHelper {
* @param id base64Decode(show_id) + IV
* @return the encryption key
*/
- private fun getKey(id: String): String? {
+ private fun getEncryptionKey(id: String): String? {
return safe {
id.map {
it.code.toString(16)
@@ -64,7 +64,7 @@ object GogoHelper {
* @param iv secret iv from site, required non-null if isUsingAdaptiveKeys is off
* @param secretKey secret key for decryption from site, required non-null if isUsingAdaptiveKeys is off
* @param secretDecryptKey secret key to decrypt the response json, required non-null if isUsingAdaptiveKeys is off
- * @param isUsingAdaptiveKeys generates keys from IV and ID, see getKey()
+ * @param isUsingAdaptiveKeys generates keys from IV and ID, see [getEncryptionKey]
* @param isUsingAdaptiveData generate encrypt-ajax data based on $("script[data-name='episode']")[0].dataset.value
*/
suspend fun extractVidstream(
@@ -89,7 +89,7 @@ object GogoHelper {
val foundIv = iv ?: (document ?: app.get(iframeUrl).document.also { document = it })
.select("""div.wrapper[class*=container]""")
.attr("class").split("-").lastOrNull() ?: return@safeApiCall
- val foundKey = secretKey ?: getKey(base64Decode(id) + foundIv) ?: return@safeApiCall
+ val foundKey = secretKey ?: getEncryptionKey(base64Decode(id) + foundIv) ?: return@safeApiCall
val foundDecryptKey = secretDecryptKey ?: foundKey
val url = Url(iframeUrl)
From 60cfeba7351f27ea52b64aaa983178a3dee9a2dc Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Wed, 8 Jul 2026 17:13:50 +0000
Subject: [PATCH 29/94] Fix: Sync delay, Progress on sync button and
runOnMainThreadNative fix (#3032)
---
.../java/com/lagradost/cloudstream3/MainActivity.kt | 6 ++++--
.../lagradost/cloudstream3/ui/result/SyncViewModel.kt | 1 +
.../java/com/lagradost/cloudstream3/utils/UIHelper.kt | 4 ++++
.../lagradost/cloudstream3/utils/Coroutines.android.kt | 10 ++++++++--
4 files changed, 17 insertions(+), 4 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
index 0cbed1809..7db33284e 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt
@@ -171,6 +171,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat
+import com.lagradost.cloudstream3.utils.UIHelper.showProgress
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API
@@ -1428,8 +1429,9 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCa
else -> {
resultviewPreviewBookmark.isEnabled = false
- resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24)
- resultviewPreviewBookmark.setText(R.string.loading)
+ resultviewPreviewBookmark.showProgress()
+ //resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24)
+ //resultviewPreviewBookmark.setText(R.string.loading)
}
}
}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt
index 6c5c64ff8..3161a1204 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt
@@ -182,6 +182,7 @@ class SyncViewModel : ViewModel() {
fun publishUserData() = ioSafe {
Log.i(TAG, "publishUserData")
val user = userData.value
+ _userDataResponse.postValue(Resource.Loading())
if (user is Resource.Success) {
syncs.forEach { (prefix, id) ->
repos.firstOrNull { it.idPrefix == prefix }?.updateStatus(id, user.value)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt
index 69f22e8ef..a848bee26 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt
@@ -597,6 +597,10 @@ object UIHelper {
fun MaterialButton.showProgress(@ColorInt tintColor: Int = this.iconTint.defaultColor) =
// Use runOnMainThreadNative to allow process on io threads, to make the code a bit cleaner
runOnMainThreadNative {
+ // No need to set it again, as then it will reset the animation
+ if(this.icon is IndeterminateDrawable<*>) {
+ return@runOnMainThreadNative
+ }
val spec = CircularProgressIndicatorSpec(
context, null, 0,
com.google.android.material.R.style.Widget_Material3_CircularProgressIndicator_ExtraSmall
diff --git a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.android.kt b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.android.kt
index 048e7fc02..22bd3b20b 100644
--- a/library/src/androidMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.android.kt
+++ b/library/src/androidMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.android.kt
@@ -1,14 +1,20 @@
package com.lagradost.cloudstream3.utils
+import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import androidx.annotation.AnyThread
import androidx.annotation.MainThread
+@SuppressLint("ThreadConstraint") // mainLooper.isCurrentThread does not switch the context
@AnyThread
actual fun runOnMainThreadNative(@MainThread work: () -> Unit) {
- val mainHandler = Handler(Looper.getMainLooper())
- mainHandler.post {
+ val mainLooper = Looper.getMainLooper()
+ if (mainLooper.isCurrentThread) {
+ // Do the work directly if we already are on the main thread, no need to enqueue it
work()
+ } else {
+ // Otherwise post it to the other main thread
+ Handler(mainLooper).post(work)
}
}
From 5cb939df69c6f6bda9aeb660e277c25ff3279119 Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Wed, 8 Jul 2026 17:29:06 +0000
Subject: [PATCH 30/94] Fix: Corrupted HSL files due to 404 (#3013)
---
.../kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt
index aa4515b30..d34f5f500 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/M3u8Helper.kt
@@ -234,6 +234,10 @@ object M3u8Helper2 {
body.close()
if (tsData.isEmpty()) throw ErrorLoadingException("no data")
+ // Some sources respond with "error 404" or similar, this checks for small responses that
+ // looks like ASCII
+ if (tsData.size < 128 && tsData.all { it >= 0 }) throw ErrorLoadingException("ASCII found instead of data")
+
return if (isEncrypted) {
getDecrypted(encryptionData, tsData, encryptionIv, index)
} else {
From a123c5935c867dbcbbcfa6a0eed07f5e862029c1 Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Wed, 8 Jul 2026 17:30:23 +0000
Subject: [PATCH 31/94] Fix slow startup in downloader
---
.../java/com/lagradost/cloudstream3/plugins/PluginManager.kt | 3 +++
1 file changed, 3 insertions(+)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
index de41cbc74..829d871d2 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt
@@ -512,6 +512,9 @@ object PluginManager {
val res = dir.mkdirs()
if (!res) {
Log.w(TAG, "Failed to create local directories")
+ // We have tried to load local plugins, but exit early.
+ // This needs to be true to prevent the downloader waiting for plugins.
+ loadedLocalPlugins = true
return
}
}
From 480d48bea48f97696a15246fbd91b335c7a66fb3 Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Wed, 8 Jul 2026 23:26:42 +0000
Subject: [PATCH 32/94] Fix: Switch focus on skip chapter (#3024)
---
.../com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
index 5f5110552..20c37dcf4 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt
@@ -1202,6 +1202,10 @@ open class FullScreenPlayer : AbstractPlayerFragment(
}
skipChapterButton.setOnClickListener {
+ // Switch focus for a better UX, as otherwise it is reset to a random button like "back button"
+ if(skipChapterButton.hasFocus()) {
+ playerPausePlay.requestFocus()
+ }
player.handleEvent(CSPlayerEvent.SkipCurrentChapter)
}
From 694c9c44134d9116caffb10e443f15ae4e732e00 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Wed, 8 Jul 2026 17:41:54 -0600
Subject: [PATCH 33/94] Add JsInterpreter to replace Rhino usage (#2812)
---
gradle/libs.versions.toml | 5 +-
library/build.gradle.kts | 1 +
.../com/lagradost/cloudstream3/MainAPI.kt | 5 +
.../cloudstream3/extractors/StreamTape.kt | 24 +-
.../cloudstream3/extractors/Userload.kt | 30 +-
.../cloudstream3/utils/JsInterpreter.kt | 1631 +++++++++++
.../cloudstream3/utils/JsInterpreterTest.kt | 2410 +++++++++++++++++
7 files changed, 4063 insertions(+), 43 deletions(-)
create mode 100644 library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt
create mode 100644 library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/JsInterpreterTest.kt
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index d0c8eef94..8935ab52a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -29,7 +29,7 @@ juniversalchardet = "2.5.0"
kotlinGradlePlugin = "2.3.20"
kotlinxAtomicfu = "0.33.0"
kotlinxCollectionsImmutable = "0.4.0"
-kotlinxCoroutinesCore = "1.11.0"
+kotlinxCoroutines = "1.11.0"
kotlinxDatetime = "0.8.0"
kotlinxSerializationJson = "1.11.0"
ksoup = "0.2.6"
@@ -96,7 +96,8 @@ kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref =
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlinGradlePlugin" }
kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "kotlinxAtomicfu" }
kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
-kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
+kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
+kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" }
diff --git a/library/build.gradle.kts b/library/build.gradle.kts
index 23351f7d0..9b769d1e5 100644
--- a/library/build.gradle.kts
+++ b/library/build.gradle.kts
@@ -76,6 +76,7 @@ kotlin {
commonTest.dependencies {
implementation(libs.kotlin.test)
+ implementation(libs.kotlinx.coroutines.test)
}
val jvmCommonMain by creating {
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
index c34eb83f6..72d69dc45 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
@@ -804,6 +804,11 @@ fun fixTitle(str: String): String {
*
* Use like the following: rhino.evaluateString(scope, js, "JavaScript", 1, null)
**/
+// Deprecate after next stable
+/* @Deprecated(
+ message = "Use JsContext or evalJs instead.",
+ level = DeprecationLevel.WARNING,
+) */
suspend fun getRhinoContext(): org.mozilla.javascript.Context {
return Coroutines.mainWork {
val rhino = org.mozilla.javascript.Context.enter()
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamTape.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamTape.kt
index 211b5ecf9..979674863 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamTape.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamTape.kt
@@ -4,8 +4,8 @@ import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.Qualities
+import com.lagradost.cloudstream3.utils.evalJs
import com.lagradost.cloudstream3.utils.newExtractorLink
-import org.mozilla.javascript.Context
class Watchadsontape : StreamTape() {
override var mainUrl = "https://watchadsontape.com"
@@ -30,24 +30,14 @@ open class StreamTape : ExtractorApi() {
override suspend fun getUrl(url: String, referer: String?): List? {
with(app.get(url)) {
- var result =
+ val result =
this.document.select("script").firstOrNull { it.html().contains("botlink').innerHTML") }
- ?.html()?.lines()?.firstOrNull{ it.contains("botlink').innerHTML") }?.let {
- val scriptContent =
- it.substringAfter(").innerHTML").replaceFirst("=", "var url =")
- val rhino = Context.enter()
- rhino.setInterpretedMode(true)
- val scope = rhino.initStandardObjects()
- var result = ""
- try {
- rhino.evaluateString(scope, scriptContent, "url", 1, null)
- result = scope.get("url", scope).toString()
- }finally {
- rhino.close()
- }
- result
+ ?.html()?.lines()?.firstOrNull { it.contains("botlink').innerHTML") }?.let {
+ val scriptContent = it.substringAfter(").innerHTML").replaceFirst("=", "var url =")
+ evalJs(scriptContent, "url")?.toString()
}
- if(!result.isNullOrEmpty()){
+
+ if (!result.isNullOrEmpty()) {
val extractedUrl = "https:${result}&stream=1"
return listOf(
newExtractorLink(
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt
index 08dcb634e..ed2c20f31 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt
@@ -3,9 +3,6 @@ package com.lagradost.cloudstream3.extractors
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.utils.*
-import org.mozilla.javascript.Context
-import org.mozilla.javascript.EvaluatorException
-import org.mozilla.javascript.Scriptable
open class Userload : ExtractorApi() {
override var name = "Userload"
@@ -32,20 +29,11 @@ open class Userload : ExtractorApi() {
return array
}
- private fun evaluateMath(mathExpression : String): String {
- val rhino = Context.enter()
- rhino.initStandardObjects()
- rhino.setInterpretedMode(true)
- val scope: Scriptable = rhino.initStandardObjects()
- return try {
- rhino.evaluateString(scope, "eval($mathExpression)", "JavaScript", 1, null).toString()
- }
- catch (e: EvaluatorException){
- ""
- }
+ private suspend fun evaluateMath(mathExpression: String): String {
+ return jsValueToString(evalJs("eval($mathExpression)"))
}
- private fun decodeVideoJs(text: String): List {
+ private suspend fun decodeVideoJs(text: String): List {
text.replace("""\s+|/\*.*?\*/""".toRegex(), "")
val data = text.split("""+(゚Д゚)[゚o゚]""")[1]
val chars = data.split("""+ (゚Д゚)[゚ε゚]+""").drop(1)
@@ -68,22 +56,16 @@ open class Userload : ExtractorApi() {
subchar.add(splitInput(v).map { evaluateMath(it).substringBefore(".") }.toString().filter { it.isDigit() })
}
var txtresult = ""
- subchar.forEach{
+ subchar.forEach {
txtresult = txtresult.plus(it.toInt(8).toChar())
}
val val1 = Regex(""""morocco="((.|\\n)*?)"&mycountry="""").find(txtresult)?.groups?.get(1)?.value.toString().drop(1).dropLast(1)
val val2 = txtresult.substringAfter("""&mycountry="+""").substringBefore(")")
- return listOf(
- val1,
- val2
- )
-
-
+ return listOf(val1, val2)
}
override suspend fun getUrl(url: String, referer: String?): List? {
-
val extractedLinksList: MutableList = mutableListOf()
val response = app.get(url).text
@@ -113,4 +95,4 @@ open class Userload : ExtractorApi() {
return extractedLinksList
}
-}
\ No newline at end of file
+}
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt
new file mode 100644
index 000000000..39d950ed1
--- /dev/null
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt
@@ -0,0 +1,1631 @@
+package com.lagradost.cloudstream3.utils
+
+import androidx.annotation.VisibleForTesting
+import com.lagradost.cloudstream3.Prerelease
+import com.lagradost.cloudstream3.mvvm.logError
+import com.lagradost.cloudstream3.utils.StringUtils.decodeUrl
+import com.lagradost.cloudstream3.utils.StringUtils.encodeUrl
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.isActive
+import kotlin.math.E
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.math.ceil
+import kotlin.math.cos
+import kotlin.math.floor
+import kotlin.math.ln
+import kotlin.math.log10
+import kotlin.math.log2
+import kotlin.math.pow
+import kotlin.math.round
+import kotlin.math.sin
+import kotlin.math.sqrt
+import kotlin.math.tan
+import kotlin.math.truncate
+import kotlin.random.Random
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.TimeMark
+import kotlin.time.TimeSource
+
+/**
+ * Lightweight pure-Kotlin JavaScript interpreter designed to replace Rhino for
+ * our own deobfuscation use-cases.
+ *
+ * Supports the subset of JS that appears in obfuscated video-hosting scripts:
+ * - Variable declarations (var / let / const)
+ * - String, number (including hex `0x..` and legacy octal `0..`) and boolean literals
+ * - Arithmetic / bitwise / comparison / logical operators
+ * - String concatenation with +
+ * - String methods: split, join, reverse, replace, charAt, charCodeAt,
+ * fromCharCode, substr, substring, slice, indexOf,
+ * trim, toLowerCase, toUpperCase, toString, length
+ * - Array literals and methods: join, reverse, split, push, pop,
+ * map, filter, forEach, length
+ * - parseInt / parseFloat / isNaN / isFinite
+ * - Math.* (sin, cos, floor, ceil, round, abs, pow, sqrt, log, max, min, random)
+ * - String.fromCharCode
+ * - Ternary operator (a ? b : c)
+ * - if / else / while / for statements
+ * - Function declarations and calls (named and anonymous)
+ * - return / break / continue
+ * - Template literals (back-tick strings)
+ * - instanceof
+ * - typeof
+ *
+ * Every evaluation is bounded by an execution budget (wall-clock time and/or instruction
+ * count, see [JS_DEFAULT_MAX_EXECUTION_TIME] / [JS_DEFAULT_MAX_INSTRUCTIONS]) so that
+ * untrusted/obfuscated scripts containing an infinite loop (such as `while(true){}`)
+ * cannot hang the calling thread forever. Note that since evaluation is synchronous, wrapping a call
+ * in `withTimeout` will not pre-empt it mid-flight (there's no suspension point for the
+ * coroutine machinery to act on) so the budget below is what actually guarantees the call
+ * returns.
+ *
+ * Usage:
+ * val result = evalJs("var x = 1+2; x")
+ * // result == 3.0 (numbers are always Double internally)
+ *
+ * // Drop-in replacement for the old Rhino-based evaluateString pattern:
+ * val ctx = JsContext()
+ * ctx.eval("var url = 'https:' + computeSuffix()")
+ * val url = ctx["url"] // retrieves the variable as a String
+ *
+ * // Or simpler:
+ * val url = evalJs("var url = 'https:' + computeSuffix()", "url")
+ */
+
+/** Default wall-clock budget for a single [evalJs] / [JsContext.eval] call before it's aborted. */
+private val JS_DEFAULT_MAX_EXECUTION_TIME: Duration = 5.seconds
+
+/** Hard backstop on statements/expressions executed, independent of wall-clock time. */
+private const val JS_DEFAULT_MAX_INSTRUCTIONS: Long = 50_000_000L
+
+/**
+ * Convert any JS runtime value to its JavaScript string representation.
+ * Mirrors what JS `String(value)` would produce.
+ */
+@Prerelease
+fun jsValueToString(v: Any?): String = toJsString(v)
+
+/**
+ * Stateful JS execution context. Keeps variables alive between [eval] calls,
+ * mimicking the Rhino "scope" object that extensions used to hold on to.
+ *
+ * Instances are created via [newJsContext], not by calling this constructor directly.
+ *
+ * @param maxExecutionTime wall-clock budget given to each [eval] call. Can still be
+ * changed after construction (e.g. from within [newJsContext]'s initializer
+ * block) as long as it's set before the first [eval]/[get]/[set] call, since the
+ * underlying interpreter is built lazily from whatever values these hold at that
+ * point. Changes made afterward have no effect.
+ * @param maxInstructions hard cap on statements/expressions executed per [eval] call,
+ * independent of wall-clock time. Same lazy-initialization caveat as
+ * [maxExecutionTime] applies.
+ * @param scope the [CoroutineScope] this context's cancellation is tied to. Supplied
+ * automatically by [newJsContext] from the caller's own coroutine context.
+ */
+@Prerelease
+class JsContext internal constructor(
+ var maxExecutionTime: Duration = JS_DEFAULT_MAX_EXECUTION_TIME,
+ var maxInstructions: Long = JS_DEFAULT_MAX_INSTRUCTIONS,
+ private val scope: CoroutineScope,
+) {
+ /**
+ * Built lazily so that any changes made to [maxExecutionTime]/[maxInstructions] inside
+ * [newJsContext]'s initializer block (before the first [eval]/[get]/[set] call) are
+ * still in effect when JsInterpreter is actually constructed. Once this has been
+ * accessed once, further changes to the two vars above have no effect.
+ */
+ private val interpreter: JsInterpreter by lazy {
+ JsInterpreter(maxExecutionTime, maxInstructions, scope)
+ }
+
+ /**
+ * Evaluate [code] in this context. Returns the last expression value.
+ *
+ * @throws CancellationException if this context's underlying coroutine scope
+ * has been cancelled, e.g. by an enclosing `withTimeout`.
+ */
+ @Throws(CancellationException::class)
+ suspend fun eval(code: String): Any? = interpreter.eval(code)
+
+ /**
+ * Retrieve a variable set by previously evaluated code, or via [set].
+ * Returns `null` if never set.
+ */
+ operator fun get(name: String): Any? = interpreter.getVar(name)
+
+ /** Expose a Kotlin value to subsequently evaluated JS code under the name [name]. */
+ operator fun set(name: String, value: Any?) = interpreter.setVar(name, value)
+}
+
+/**
+ * Creates a new [JsContext], running [initializer] on it before returning.
+ *
+ * The context's cancellation is automatically tied to whichever coroutine calls this
+ * function. If that coroutine is later cancelled (e.g. an enclosing `withTimeout`
+ * expires), any in-flight or subsequent [JsContext.eval] call on the returned
+ * context will throw [CancellationException].
+ *
+ * [JsContext.maxExecutionTime] / [JsContext.maxInstructions] can be changed from
+ * within [initializer] (via `this.maxExecutionTime = ...`) and will take effect, since
+ * the underlying interpreter isn't built until the context is first used.
+ *
+ * Usage:
+ * val ctx = newJsContext {
+ * maxInstructions = 10_000
+ * set("x", 1.0)
+ * eval("x + 1")
+ * }
+ */
+@Prerelease
+suspend fun newJsContext(
+ initializer: suspend JsContext.() -> Unit = {},
+): JsContext {
+ val scope = CoroutineScope(currentCoroutineContext())
+ return JsContext(scope = scope).apply { initializer() }
+}
+
+/**
+ * Evaluate [js] and return its last value, or the value of [variable] if specified.
+ * Convenience wrapper for one-shot evaluations, equivalent to the old
+ * `rhino.evaluateString(scope, js, ...)`.
+ *
+ * Cancellation is automatically tied to whichever coroutine calls this function. If
+ * that coroutine is cancelled (e.g. an enclosing `withTimeout` expires), this call
+ * throws [CancellationException].
+ *
+ * @param js The JavaScript code to evaluate.
+ * @param variable Optional variable name to retrieve from the scope after evaluation.
+ * @param maxExecutionTime wall-clock budget before the script is forcibly aborted.
+ * Defaults to [JS_DEFAULT_MAX_EXECUTION_TIME]; pass a smaller value for time-sensitive
+ * call sites (e.g. inside a `withTimeout`, which cannot itself interrupt this call).
+ * @param maxInstructions hard cap on statements/expressions executed, independent of
+ * wall-clock time. Defaults to [JS_DEFAULT_MAX_INSTRUCTIONS].
+ * @return The last expression value, or the named variable value if [variable] is specified.
+ * Returns [Unit] on evaluation failure, timeout, or when the result is JS undefined.
+ * JS null is represented as Kotlin null. Use [jsValueToString] to convert to a JS string.
+ */
+@Prerelease
+@Throws(CancellationException::class)
+suspend fun evalJs(
+ js: String,
+ variable: String? = null,
+ maxExecutionTime: Duration = JS_DEFAULT_MAX_EXECUTION_TIME,
+ maxInstructions: Long = JS_DEFAULT_MAX_INSTRUCTIONS,
+): Any? {
+ val scope = CoroutineScope(currentCoroutineContext())
+ return evalJsInternal(js, variable, maxExecutionTime, maxInstructions, scope)
+}
+
+@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+internal fun evalJsInternal(
+ js: String,
+ variable: String? = null,
+ maxExecutionTime: Duration = JS_DEFAULT_MAX_EXECUTION_TIME,
+ maxInstructions: Long = JS_DEFAULT_MAX_INSTRUCTIONS,
+ scope: CoroutineScope? = null,
+): Any? {
+ val interpreter = JsInterpreter(maxExecutionTime, maxInstructions, scope)
+ val result = interpreter.eval(js)
+ return if (variable != null) interpreter.getVar(variable) else result
+}
+
+private enum class TT {
+ NUMBER, STRING, IDENT, PLUS, MINUS, STAR, SLASH, PERCENT, POW, EQ, EQEQ, EQEQEQ,
+ NEQ, NEQEQ, LT, LTEQ, GT, GTEQ, AND, OR, NOT, AMP, PIPE, CARET, TILDE,
+ LSHIFT, RSHIFT, URSHIFT, PLUSEQ, MINUSEQ, STAREQ, SLASHEQ, PERCENTEQ, POWEQ,
+ PLUSPLUS, MINUSMINUS, DOT, COMMA, SEMI, COLON, QUESTION, LPAREN, RPAREN,
+ LBRACE, RBRACE, LBRACKET, RBRACKET, EOF
+}
+
+private data class Token(val type: TT, val raw: String, val pos: Int)
+
+private class Lexer(private val src: String) {
+ var pos = 0
+ private val tokens = mutableListOf()
+ private var idx = 0
+
+ init { tokenize() }
+
+ private fun tokenize() {
+ while (pos < src.length) {
+ skipWhitespaceAndComments()
+ if (pos >= src.length) break
+ val c = src[pos]
+ when {
+ c.isDigit() || (c == '.' && pos + 1 < src.length && src[pos + 1].isDigit()) -> readNumber()
+ c == '"' || c == '\'' || c == '`' -> readString(c)
+ c.isLetter() || c == '_' || c == '$' -> readIdent()
+ else -> readOp()
+ }
+ }
+ tokens.add(Token(TT.EOF, "", pos))
+ }
+
+ private fun skipWhitespaceAndComments() {
+ while (pos < src.length) {
+ val c = src[pos]
+ if (c.isWhitespace()) { pos++; continue }
+ if (c == '/' && pos + 1 < src.length) {
+ when (src[pos + 1]) {
+ '/' -> { pos = src.indexOf('\n', pos).let { if (it < 0) src.length else it + 1 }; continue }
+ '*' -> {
+ val end = src.indexOf("*/", pos + 2)
+ pos = if (end < 0) src.length else end + 2; continue
+ }
+ else -> {}
+ }
+ }
+ break
+ }
+ }
+
+ private fun readNumber() {
+ val start = pos
+ if (pos + 1 < src.length && src[pos] == '0' &&
+ (src[pos + 1] == 'x' || src[pos + 1] == 'X')
+ ) {
+ pos += 2
+ while (pos < src.length && src[pos].isLetterOrDigit()) pos++
+ } else {
+ while (pos < src.length && (src[pos].isDigit() || src[pos] == '.')) pos++
+ if (pos < src.length && (src[pos] == 'e' || src[pos] == 'E')) {
+ pos++
+ if (pos < src.length && (src[pos] == '+' || src[pos] == '-')) pos++
+ while (pos < src.length && src[pos].isDigit()) pos++
+ }
+ }
+ tokens.add(Token(TT.NUMBER, src.substring(start, pos), start))
+ }
+
+ private fun readString(quote: Char) {
+ val start = pos++
+ val sb = StringBuilder()
+ if (quote == '`') {
+ // template literal. We flatten it to a plain string (no interpolation yet)
+ while (pos < src.length && src[pos] != '`') {
+ if (src[pos] == '\\' && pos + 1 < src.length) {
+ sb.append(unescape(src[++pos])); pos++
+ } else sb.append(src[pos++])
+ }
+ if (pos < src.length) pos++ // consume closing `
+ } else {
+ while (pos < src.length && src[pos] != quote) {
+ if (src[pos] == '\\' && pos + 1 < src.length) {
+ sb.append(unescape(src[++pos])); pos++
+ } else sb.append(src[pos++])
+ }
+ if (pos < src.length) pos++
+ }
+ tokens.add(Token(TT.STRING, sb.toString(), start))
+ }
+
+ private fun unescape(c: Char): Char = when (c) {
+ 'n' -> '\n'; 'r' -> '\r'; 't' -> '\t'; 'b' -> '\b'
+ '\'' -> '\''; '"' -> '"'; '\\' -> '\\'; '`' -> '`'
+ else -> c
+ }
+
+ private fun readIdent() {
+ val start = pos
+ while (pos < src.length && (src[pos].isLetterOrDigit() || src[pos] == '_' || src[pos] == '$')) pos++
+ tokens.add(Token(TT.IDENT, src.substring(start, pos), start))
+ }
+
+ private fun readOp() {
+ val start = pos
+ val c = src[pos++]
+ val next = if (pos < src.length) src[pos] else '\u0000'
+ val nn = if (pos + 1 < src.length) src[pos + 1] else '\u0000'
+ fun adv(t: TT, n: Int = 0): Token { pos += n; return Token(t, "", start) }
+ val tok = when (c) {
+ '+' -> when (next) { '+' -> adv(TT.PLUSPLUS, 1); '=' -> adv(TT.PLUSEQ, 1); else -> Token(TT.PLUS, "+", start) }
+ '-' -> when (next) { '-' -> adv(TT.MINUSMINUS, 1); '=' -> adv(TT.MINUSEQ, 1); else -> Token(TT.MINUS, "-", start) }
+ '*' -> when {
+ next == '*' && nn == '=' -> adv(TT.POWEQ, 2)
+ next == '*' -> adv(TT.POW, 1)
+ next == '=' -> adv(TT.STAREQ, 1)
+ else -> Token(TT.STAR, "*", start)
+ }
+ '/' -> if (next == '=') adv(TT.SLASHEQ, 1) else Token(TT.SLASH, "/", start)
+ '%' -> if (next == '=') adv(TT.PERCENTEQ, 1) else Token(TT.PERCENT, "%", start)
+ '=' -> when { next == '=' && nn == '=' -> adv(TT.EQEQEQ, 2); next == '=' -> adv(TT.EQEQ, 1); else -> Token(TT.EQ, "=", start) }
+ '!' -> when { next == '=' && nn == '=' -> adv(TT.NEQEQ, 2); next == '=' -> adv(TT.NEQ, 1); else -> Token(TT.NOT, "!", start) }
+ '<' -> when { next == '<' -> adv(TT.LSHIFT, 1); next == '=' -> adv(TT.LTEQ, 1); else -> Token(TT.LT, "<", start) }
+ '>' -> when { next == '>' && nn == '>' -> adv(TT.URSHIFT, 2); next == '>' -> adv(TT.RSHIFT, 1); next == '=' -> adv(TT.GTEQ, 1); else -> Token(TT.GT, ">", start) }
+ '&' -> if (next == '&') adv(TT.AND, 1) else Token(TT.AMP, "&", start)
+ '|' -> if (next == '|') adv(TT.OR, 1) else Token(TT.PIPE, "|", start)
+ '^' -> Token(TT.CARET, "^", start)
+ '~' -> Token(TT.TILDE, "~", start)
+ '.' -> Token(TT.DOT, ".", start)
+ ',' -> Token(TT.COMMA, ",", start)
+ ';' -> Token(TT.SEMI, ";", start)
+ ':' -> Token(TT.COLON, ":", start)
+ '?' -> Token(TT.QUESTION, "?", start)
+ '(' -> Token(TT.LPAREN, "(", start)
+ ')' -> Token(TT.RPAREN, ")", start)
+ '{' -> Token(TT.LBRACE, "{", start)
+ '}' -> Token(TT.RBRACE, "}", start)
+ '[' -> Token(TT.LBRACKET, "[", start)
+ ']' -> Token(TT.RBRACKET, "]", start)
+ else -> Token(TT.SEMI, "", start) // swallow unknown
+ }
+ tokens.add(tok)
+ }
+
+ fun peek(offset: Int = 0): Token = tokens.getOrElse(idx + offset) { tokens.last() }
+ fun consume(): Token = tokens[idx++]
+ fun expect(t: TT): Token {
+ val tok = consume()
+ if (tok.type != t) throw RuntimeException("Expected $t got ${tok.type} ('${tok.raw}') at ${tok.pos}")
+ return tok
+ }
+ fun matchIf(t: TT): Boolean = if (peek().type == t) { consume(); true } else false
+}
+
+private sealed class Node
+private data class NumLit(val v: Double) : Node()
+private data class StrLit(val v: String) : Node()
+private data class BoolLit(val v: Boolean) : Node()
+private object NullLit : Node()
+private object UndefinedLit : Node()
+private data class Ident(val name: String) : Node()
+private data class ArrayLit(val elems: List) : Node()
+private data class ObjLit(val pairs: List>) : Node()
+private data class MemberExpr(val obj: Node, val prop: Node, val computed: Boolean) : Node()
+private data class CallExpr(val callee: Node, val args: List) : Node()
+private data class NewExpr(val callee: Node, val args: List) : Node()
+private data class UnaryExpr(val op: String, val expr: Node, val prefix: Boolean) : Node()
+private data class BinExpr(val op: String, val left: Node, val right: Node) : Node()
+private data class AssignExpr(val op: String, val left: Node, val right: Node) : Node()
+private data class CondExpr(val test: Node, val cons: Node, val alt: Node) : Node()
+private data class TypeofExpr(val expr: Node) : Node()
+private data class SeqExpr(val exprs: List) : Node()
+private data class FuncExpr(val name: String?, val params: List, val body: List) : Node()
+private data class VarDecl(val decls: List>) : Node()
+private data class ExprStmt(val expr: Node) : Node()
+private data class BlockStmt(val stmts: List) : Node()
+private data class ReturnStmt(val expr: Node?) : Node()
+private data class IfStmt(val test: Node, val cons: Node, val alt: Node?) : Node()
+private data class WhileStmt(val test: Node, val body: Node) : Node()
+private data class ForStmt(val init: Node?, val test: Node?, val update: Node?, val body: Node) : Node()
+private data class ForInStmt(val decl: String, val obj: Node, val body: Node) : Node()
+private object BreakStmt : Node()
+private object ContinueStmt : Node()
+private data class TryCatch(val body: List, val catchParam: String?, val catchBody: List?, val finallyBody: List?) : Node()
+private data class ThrowStmt(val expr: Node) : Node()
+
+private class Parser(private val lex: Lexer) {
+
+ fun parseProgram(): List {
+ val stmts = mutableListOf()
+ while (lex.peek().type != TT.EOF) stmts.add(parseStatement())
+ return stmts
+ }
+
+ private fun parseStatement(): Node {
+ return when (lex.peek().type) {
+ TT.LBRACE -> parseBlock()
+ TT.SEMI -> { lex.consume(); BlockStmt(emptyList()) }
+ TT.IDENT -> when (lex.peek().raw) {
+ "var", "let", "const" -> parseVarDecl()
+ "function" -> parseFunctionDecl()
+ "return" -> parseReturn()
+ "if" -> parseIf()
+ "while" -> parseWhile()
+ "for" -> parseFor()
+ "break" -> { lex.consume(); lex.matchIf(TT.SEMI); BreakStmt }
+ "continue" -> { lex.consume(); lex.matchIf(TT.SEMI); ContinueStmt }
+ "try" -> parseTryCatch()
+ "throw" -> parseThrow()
+ "typeof" -> parseExprStmt()
+ else -> parseExprStmt()
+ }
+ else -> parseExprStmt()
+ }
+ }
+
+ private fun parseBlock(): BlockStmt {
+ lex.expect(TT.LBRACE)
+ val stmts = mutableListOf()
+ while (lex.peek().type != TT.RBRACE && lex.peek().type != TT.EOF) stmts.add(parseStatement())
+ lex.expect(TT.RBRACE)
+ return BlockStmt(stmts)
+ }
+
+ private fun parseVarDecl(): VarDecl {
+ lex.consume() // var / let / const
+ val decls = mutableListOf>()
+ do {
+ val name = lex.expect(TT.IDENT).raw
+ val init = if (lex.matchIf(TT.EQ)) parseAssign() else null
+ decls.add(name to init)
+ } while (lex.matchIf(TT.COMMA))
+ lex.matchIf(TT.SEMI)
+ return VarDecl(decls)
+ }
+
+ private fun parseFunctionDecl(): Node {
+ lex.consume() // "function"
+ val name = if (lex.peek().type == TT.IDENT) lex.consume().raw else null
+ val params = parseParams()
+ val body = parseBlock().stmts
+ return if (name != null) VarDecl(listOf(name to FuncExpr(name, params, body))) else FuncExpr(name, params, body)
+ }
+
+ private fun parseParams(): List {
+ lex.expect(TT.LPAREN)
+ val params = mutableListOf()
+ while (lex.peek().type != TT.RPAREN && lex.peek().type != TT.EOF) {
+ params.add(lex.expect(TT.IDENT).raw)
+ if (!lex.matchIf(TT.COMMA)) break
+ }
+ lex.expect(TT.RPAREN)
+ return params
+ }
+
+ private fun parseReturn(): ReturnStmt {
+ lex.consume()
+ val expr = if (lex.peek().type == TT.SEMI || lex.peek().type == TT.RBRACE || lex.peek().type == TT.EOF) null else parseAssign()
+ lex.matchIf(TT.SEMI)
+ return ReturnStmt(expr)
+ }
+
+ private fun parseIf(): IfStmt {
+ lex.consume()
+ lex.expect(TT.LPAREN)
+ val test = parseAssign()
+ lex.expect(TT.RPAREN)
+ val cons = parseStatement()
+ val alt = if (lex.peek().type == TT.IDENT && lex.peek().raw == "else") { lex.consume(); parseStatement() } else null
+ return IfStmt(test, cons, alt)
+ }
+
+ private fun parseWhile(): WhileStmt {
+ lex.consume()
+ lex.expect(TT.LPAREN)
+ val test = parseAssign()
+ lex.expect(TT.RPAREN)
+ return WhileStmt(test, parseStatement())
+ }
+
+ private fun parseFor(): Node {
+ lex.consume()
+ lex.expect(TT.LPAREN)
+ // for..in check
+ if (lex.peek().type == TT.IDENT && (lex.peek().raw == "var" || lex.peek().raw == "let" || lex.peek().raw == "const")) {
+ val savedIdx = lex.peek()
+ lex.consume()
+ val varName = lex.expect(TT.IDENT).raw
+ if (lex.peek().type == TT.IDENT && lex.peek().raw == "in") {
+ lex.consume()
+ val obj = parseAssign()
+ lex.expect(TT.RPAREN)
+ return ForInStmt(varName, obj, parseStatement())
+ }
+ // backtrack is hard so we just reconstruct as normal for
+ val init: Node? = if (lex.peek().type != TT.SEMI) {
+ val initExpr = if (lex.matchIf(TT.EQ)) parseAssign() else null
+ VarDecl(listOf(varName to initExpr))
+ } else VarDecl(listOf(varName to null))
+ lex.matchIf(TT.SEMI)
+ val test = if (lex.peek().type != TT.SEMI) parseAssign() else null
+ lex.matchIf(TT.SEMI)
+ val update = if (lex.peek().type != TT.RPAREN) parseAssign() else null
+ lex.expect(TT.RPAREN)
+ return ForStmt(init, test, update, parseStatement())
+ }
+ val init = if (lex.peek().type != TT.SEMI) parseAssign() else null
+ lex.matchIf(TT.SEMI)
+ val test = if (lex.peek().type != TT.SEMI) parseAssign() else null
+ lex.matchIf(TT.SEMI)
+ val update = if (lex.peek().type != TT.RPAREN) parseAssign() else null
+ lex.expect(TT.RPAREN)
+ return ForStmt(init, test, update, parseStatement())
+ }
+
+ private fun parseTryCatch(): TryCatch {
+ lex.consume() // "try"
+ val body = parseBlock().stmts
+ var catchParam: String? = null
+ var catchBody: List? = null
+ if (lex.peek().type == TT.IDENT && lex.peek().raw == "catch") {
+ lex.consume()
+ if (lex.matchIf(TT.LPAREN)) {
+ catchParam = lex.expect(TT.IDENT).raw
+ lex.expect(TT.RPAREN)
+ }
+ catchBody = parseBlock().stmts
+ }
+ val finallyBody = if (lex.peek().type == TT.IDENT && lex.peek().raw == "finally") {
+ lex.consume(); parseBlock().stmts
+ } else null
+ return TryCatch(body, catchParam, catchBody, finallyBody)
+ }
+
+ private fun parseThrow(): ThrowStmt {
+ lex.consume()
+ val expr = parseAssign()
+ lex.matchIf(TT.SEMI)
+ return ThrowStmt(expr)
+ }
+
+ private fun parseExprStmt(): Node {
+ val expr = parseSeq()
+ lex.matchIf(TT.SEMI)
+ return ExprStmt(expr)
+ }
+
+ private fun parseSeq(): Node {
+ val first = parseAssign()
+ if (lex.peek().type != TT.COMMA) return first
+ val exprs = mutableListOf(first)
+ while (lex.matchIf(TT.COMMA)) exprs.add(parseAssign())
+ return SeqExpr(exprs)
+ }
+
+ private fun parseAssign(): Node {
+ val left = parseTernary()
+ val op = when (lex.peek().type) {
+ TT.EQ -> "="; TT.PLUSEQ -> "+="; TT.MINUSEQ -> "-="; TT.STAREQ -> "*="; TT.SLASHEQ -> "/="; TT.PERCENTEQ -> "%="; TT.POWEQ -> "**="
+ else -> return left
+ }
+ lex.consume()
+ return AssignExpr(op, left, parseAssign())
+ }
+
+ private fun parseTernary(): Node {
+ val test = parseOr()
+ if (!lex.matchIf(TT.QUESTION)) return test
+ val cons = parseAssign()
+ lex.expect(TT.COLON)
+ return CondExpr(test, cons, parseAssign())
+ }
+
+ private fun parseOr(): Node = parseBin(::parseAnd, TT.OR to "||")
+ private fun parseAnd(): Node = parseBin(::parseBitor, TT.AND to "&&")
+ private fun parseBitor(): Node = parseBin(::parseBitxor, TT.PIPE to "|")
+ private fun parseBitxor(): Node = parseBin(::parseBitand, TT.CARET to "^")
+ private fun parseBitand(): Node = parseBin(::parseEq, TT.AMP to "&")
+ private fun parseEq(): Node = parseBin(::parseRel, TT.EQEQ to "==", TT.EQEQEQ to "===", TT.NEQ to "!=", TT.NEQEQ to "!==")
+ private fun parseRel(): Node {
+ var left = parseShift()
+ while (true) {
+ left = when {
+ lex.peek().type == TT.LT -> { lex.consume(); BinExpr("<", left, parseShift()) }
+ lex.peek().type == TT.LTEQ -> { lex.consume(); BinExpr("<=", left, parseShift()) }
+ lex.peek().type == TT.GT -> { lex.consume(); BinExpr(">", left, parseShift()) }
+ lex.peek().type == TT.GTEQ -> { lex.consume(); BinExpr(">=", left, parseShift()) }
+ lex.peek().type == TT.IDENT && lex.peek().raw == "in" -> { lex.consume(); BinExpr("in", left, parseShift()) }
+ lex.peek().type == TT.IDENT && lex.peek().raw == "instanceof" -> { lex.consume(); BinExpr("instanceof", left, parseShift()) }
+ else -> break
+ }
+ }
+ return left
+ }
+ private fun parseShift(): Node = parseBin(::parseAdd, TT.LSHIFT to "<<", TT.RSHIFT to ">>", TT.URSHIFT to ">>>")
+ private fun parseAdd(): Node = parseBin(::parseMul, TT.PLUS to "+", TT.MINUS to "-")
+ private fun parseMul(): Node = parseBin(::parsePow, TT.STAR to "*", TT.SLASH to "/", TT.PERCENT to "%")
+
+ /** `**` binds tighter than `* / %` and is right-associative: `2 ** 3 ** 2 == 2 ** (3 ** 2)`. */
+ private fun parsePow(): Node {
+ val base = parseUnary()
+ if (lex.peek().type == TT.POW) {
+ lex.consume()
+ return BinExpr("**", base, parsePow())
+ }
+ return base
+ }
+
+ private fun parseBin(next: () -> Node, vararg ops: Pair): Node {
+ var left = next()
+ while (true) {
+ val op = ops.firstOrNull { it.first == lex.peek().type } ?: break
+ lex.consume()
+ left = BinExpr(op.second, left, next())
+ }
+ return left
+ }
+
+ private fun parseUnary(): Node {
+ return when (lex.peek().type) {
+ TT.MINUS -> { lex.consume(); UnaryExpr("-", parseUnary(), true) }
+ TT.PLUS -> { lex.consume(); UnaryExpr("+", parseUnary(), true) }
+ TT.NOT -> { lex.consume(); UnaryExpr("!", parseUnary(), true) }
+ TT.TILDE -> { lex.consume(); UnaryExpr("~", parseUnary(), true) }
+ TT.PLUSPLUS -> { lex.consume(); UnaryExpr("++", parsePostfix(), true) }
+ TT.MINUSMINUS -> { lex.consume(); UnaryExpr("--", parsePostfix(), true) }
+ TT.IDENT -> when (lex.peek().raw) {
+ "typeof" -> { lex.consume(); TypeofExpr(parseUnary()) }
+ "void" -> { lex.consume(); parseUnary(); UndefinedLit }
+ "new" -> parseNew()
+ else -> parsePostfix()
+ }
+ else -> parsePostfix()
+ }
+ }
+
+ private fun parseNew(): Node {
+ lex.consume() // "new"
+ val callee = parsePrimary()
+ val args = if (lex.peek().type == TT.LPAREN) parseArgs() else emptyList()
+ var expr: Node = NewExpr(callee, args)
+ // Allow member access and calls on the constructed object: new Foo().bar, new Foo()[0]
+ while (true) {
+ expr = when (lex.peek().type) {
+ TT.DOT -> {
+ lex.consume()
+ val prop = lex.expect(TT.IDENT).raw
+ MemberExpr(expr, StrLit(prop), false)
+ }
+ TT.LBRACKET -> {
+ lex.consume()
+ val prop = parseAssign()
+ lex.expect(TT.RBRACKET)
+ MemberExpr(expr, prop, true)
+ }
+ TT.LPAREN -> CallExpr(expr, parseArgs())
+ else -> break
+ }
+ }
+ return expr
+ }
+
+ private fun parsePostfix(): Node {
+ var expr = parseCall()
+ while (true) {
+ expr = when (lex.peek().type) {
+ TT.PLUSPLUS -> { lex.consume(); UnaryExpr("++", expr, false) }
+ TT.MINUSMINUS -> { lex.consume(); UnaryExpr("--", expr, false) }
+ else -> break
+ }
+ }
+ return expr
+ }
+
+ private fun parseCall(): Node {
+ var expr = parsePrimary()
+ while (true) {
+ expr = when (lex.peek().type) {
+ TT.LPAREN -> CallExpr(expr, parseArgs())
+ TT.DOT -> {
+ lex.consume()
+ val prop = lex.expect(TT.IDENT).raw
+ MemberExpr(expr, StrLit(prop), false)
+ }
+ TT.LBRACKET -> {
+ lex.consume()
+ val prop = parseAssign()
+ lex.expect(TT.RBRACKET)
+ MemberExpr(expr, prop, true)
+ }
+ else -> break
+ }
+ }
+ return expr
+ }
+
+ private fun parseArgs(): List {
+ lex.expect(TT.LPAREN)
+ val args = mutableListOf()
+ while (lex.peek().type != TT.RPAREN && lex.peek().type != TT.EOF) {
+ args.add(parseAssign())
+ if (!lex.matchIf(TT.COMMA)) break
+ }
+ lex.expect(TT.RPAREN)
+ return args
+ }
+
+ private fun parsePrimary(): Node {
+ val tok = lex.peek()
+ return when (tok.type) {
+ TT.NUMBER -> {
+ lex.consume()
+ val raw = tok.raw
+ val value = when {
+ raw.startsWith("0x") || raw.startsWith("0X") ->
+ raw.drop(2).toLongOrNull(16)?.toDouble() ?: Double.NaN
+ // Legacy octal literal, a leading zero followed only by octal digits
+ // (0-7), e.g. "010" == 8. If any digit is 8/9 it's just decimal ("08" == 8).
+ raw.length > 1 && raw[0] == '0' && raw.all { it in '0'..'7' } ->
+ raw.toLongOrNull(8)?.toDouble() ?: Double.NaN
+ else -> raw.toDoubleOrNull() ?: Double.NaN
+ }
+ NumLit(value)
+ }
+ TT.STRING -> { lex.consume(); StrLit(tok.raw) }
+ TT.LPAREN -> {
+ lex.consume()
+ val expr = parseSeq()
+ lex.expect(TT.RPAREN)
+ expr
+ }
+ TT.LBRACKET -> {
+ lex.consume()
+ val elems = mutableListOf()
+ while (lex.peek().type != TT.RBRACKET && lex.peek().type != TT.EOF) {
+ if (lex.peek().type == TT.COMMA) {
+ // Elision, a "hole" in the array, e.g. the middle slot of [1,,3].
+ // Reads back as undefined but still occupies a slot/length.
+ elems.add(UndefinedLit)
+ lex.consume()
+ } else {
+ elems.add(parseAssign())
+ if (lex.peek().type == TT.COMMA) lex.consume() else break
+ }
+ }
+ lex.expect(TT.RBRACKET)
+ ArrayLit(elems)
+ }
+ TT.LBRACE -> {
+ lex.consume()
+ val pairs = mutableListOf>()
+ while (lex.peek().type != TT.RBRACE && lex.peek().type != TT.EOF) {
+ val key = when (lex.peek().type) {
+ TT.IDENT, TT.STRING -> lex.consume().raw
+ TT.NUMBER -> lex.consume().raw
+ else -> lex.consume().raw
+ }
+ lex.expect(TT.COLON)
+ pairs.add(key to parseAssign())
+ lex.matchIf(TT.COMMA)
+ }
+ lex.expect(TT.RBRACE)
+ ObjLit(pairs)
+ }
+ TT.IDENT -> {
+ val name = tok.raw
+ lex.consume()
+ when (name) {
+ "true" -> BoolLit(true)
+ "false" -> BoolLit(false)
+ "null" -> NullLit
+ "undefined" -> UndefinedLit
+ "function" -> {
+ val fname = if (lex.peek().type == TT.IDENT) lex.consume().raw else null
+ val params = parseParams()
+ FuncExpr(fname, params, parseBlock().stmts)
+ }
+ else -> Ident(name)
+ }
+ }
+ else -> { lex.consume(); UndefinedLit }
+ }
+ }
+}
+
+private class ReturnSignal(val value: Any?) : Throwable()
+private object BreakSignal : Throwable()
+private object ContinueSignal : Throwable()
+private class ThrowSignal(val value: Any?) : Throwable()
+
+/**
+ * Thrown when the enclosing [CoroutineScope] is cancelled (e.g. by [withTimeout]).
+ * Extends [CancellationException] so coroutines recognize and handle it correctly.
+ *
+ * We use a dedicated subclass rather than [CancellationException] directly so that
+ * tests can use [assertFailsWith]<[JsCancellationException]> to verify that cancellation
+ * originated from the interpreter's scope check rather than some other source. The class
+ * is [internal] rather than private for the same reason.
+ *
+ * The [TryCatch] handler in [JsInterpreter.execNode] explicitly rethrows this before
+ * its generic `catch (Exception)` clause, so a JS script's own try/catch block cannot
+ * swallow it and keep a cancelled loop alive.
+ */
+@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+internal class JsCancellationException(message: String) : CancellationException(message)
+
+/**
+ * Internal signal thrown once a script exceeds its execution budget (time or instruction
+ * count), or the enclosing [CoroutineScope] has been cancelled.
+ *
+ * Deliberately extends [Throwable] rather than [Exception]. The interpreter's own
+ * `try`/`catch` node handling (see [JsInterpreter.execNode]'s `TryCatch` branch) only catches
+ * `Exception`, so a JS script can't wrap an infinite loop in its own try/catch and swallow this,
+ * keeping the loop alive forever.
+ */
+private class JsExecutionLimitExceeded(message: String) : Throwable(message)
+
+private fun toNumber(v: Any?): Double = when (v) {
+ null -> 0.0 // In JS Number(null) === 0
+ is Unit -> Double.NaN // In JS Number(undefined) === NaN
+ is Double -> v
+ is Boolean -> if (v) 1.0 else 0.0
+ is String -> stringToNumber(v)
+ is JsList -> stringToNumber(toJsString(v))
+ else -> Double.NaN
+}
+
+/**
+ * Returns the numeric value of [c] in the given [radix] (2..36), or -1 if [c] isn't a valid
+ * digit for that radix. Only handles ASCII '0'-'9'/'a'-'z'/'A'-'Z', which is all JS
+ * number syntax (and parseInt) ever recognizes anyway.
+ */
+private fun digitValue(c: Char, radix: Int): Int {
+ val d = when (c) {
+ in '0'..'9' -> c - '0'
+ in 'a'..'z' -> c - 'a' + 10
+ in 'A'..'Z' -> c - 'A' + 10
+ else -> return -1
+ }
+ return if (d < radix) d else -1
+}
+
+/** Mirrors the JS ToNumber(string) abstract operation closely enough for our use-cases. */
+private fun stringToNumber(s: String): Double {
+ val trimmed = s.trim()
+ if (trimmed.isEmpty()) return 0.0 // In JS Number("") === 0, Number(" ") === 0
+ when (trimmed) {
+ "Infinity", "+Infinity" -> return Double.POSITIVE_INFINITY
+ "-Infinity" -> return Double.NEGATIVE_INFINITY
+ }
+ return when {
+ trimmed.startsWith("0x") || trimmed.startsWith("0X") -> trimmed.drop(2).toLongOrNull(16)?.toDouble() ?: Double.NaN
+ trimmed.startsWith("0o") || trimmed.startsWith("0O") -> trimmed.drop(2).toLongOrNull(8)?.toDouble() ?: Double.NaN
+ trimmed.startsWith("0b") || trimmed.startsWith("0B") -> trimmed.drop(2).toLongOrNull(2)?.toDouble() ?: Double.NaN
+ else -> trimmed.toDoubleOrNull() ?: Double.NaN
+ }
+}
+
+private fun toBoolean(v: Any?): Boolean = when (v) {
+ null, is Unit -> false
+ is Boolean -> v
+ is Double -> v != 0.0 && !v.isNaN()
+ is String -> v.isNotEmpty()
+ is JsList -> true
+ is JsObject -> true
+ else -> true
+}
+
+/** Array.prototype.join/toString treats holes/null/undefined elements as the empty string. */
+private fun joinElement(e: Any?): String = if (e == null || e is Unit) "" else toJsString(e)
+
+private fun toJsString(v: Any?): String = when (v) {
+ null -> "null"
+ is Unit -> "undefined"
+ is Boolean -> v.toString()
+ is Double -> if (v == floor(v) && !v.isInfinite()) v.toLong().toString() else v.toString()
+ is String -> v
+ is JsList -> v.elements.joinToString(",") { joinElement(it) }
+ is JsObject -> "[object Object]"
+ is NativeFn -> v.toString()
+ is JsFunction -> "function ${v.name ?: ""}() { [native code] }"
+ else -> v.toString()
+}
+
+private fun strictEq(a: Any?, b: Any?): Boolean = when {
+ a is Double && b is Double -> a == b
+ a is String && b is String -> a == b
+ a is Boolean && b is Boolean -> a == b
+ a == null && b == null -> true
+ a is Unit && b is Unit -> true
+ else -> a === b
+}
+
+private fun looseEq(a: Any?, b: Any?): Boolean {
+ if (strictEq(a, b)) return true
+ // null == undefined
+ val aNullish = a == null || a is Unit
+ val bNullish = b == null || b is Unit
+ if (aNullish || bNullish) return aNullish && bNullish
+ // number coercion
+ val an = a is Double || a is Boolean
+ val bn = b is Double || b is Boolean
+ if (an || bn) return toNumber(a) == toNumber(b)
+ if (a is String || b is String) return toJsString(a) == toJsString(b)
+ return false
+}
+
+private class JsList(val elements: MutableList = mutableListOf()) {
+ var length: Int
+ get() = elements.size
+ set(v) { while (elements.size < v) elements.add(Unit); while (elements.size > v) elements.removeAt(elements.size - 1) }
+
+ operator fun get(idx: Int): Any? = elements.getOrElse(idx) { Unit }
+ operator fun set(idx: Int, v: Any?) { while (elements.size <= idx) elements.add(Unit); elements[idx] = v }
+}
+
+private class JsObject(val props: MutableMap = mutableMapOf(), var constructor: JsFunction? = null)
+
+private class JsFunction(
+ val name: String?,
+ val params: List,
+ val body: List,
+ val closure: Scope,
+)
+
+private class Scope(val parent: Scope? = null) {
+ val vars = mutableMapOf()
+
+ fun get(name: String): Any? {
+ if (vars.containsKey(name)) return vars[name]
+ return parent?.get(name) ?: Unit
+ }
+
+ fun set(name: String, value: Any?) {
+ val scope = findOwner(name)
+ if (scope != null) scope.vars[name] = value else vars[name] = value
+ }
+
+ fun define(name: String, value: Any?) { vars[name] = value }
+
+ private fun findOwner(name: String): Scope? =
+ if (vars.containsKey(name)) this else parent?.findOwner(name)
+}
+
+private class JsInterpreter(
+ private val maxExecutionTime: Duration = JS_DEFAULT_MAX_EXECUTION_TIME,
+ private val maxInstructions: Long = JS_DEFAULT_MAX_INSTRUCTIONS,
+ private val scope: CoroutineScope? = null,
+) {
+ private val globalScope = Scope()
+
+ // Execution budget, reset at the start of every top-level eval() call.
+ private var instructionCount = 0L
+ private var startMark: TimeMark = TimeSource.Monotonic.markNow()
+
+ init { installGlobals() }
+
+ private fun installGlobals() {
+ val mathObj = JsObject(mutableMapOf(
+ "PI" to PI, "E" to E,
+ "floor" to nativeFn("floor") { args -> floor(toNumber(args.getOrNull(0))) },
+ "ceil" to nativeFn("ceil") { args -> ceil(toNumber(args.getOrNull(0))) },
+ "round" to nativeFn("round") { args -> round(toNumber(args.getOrNull(0))) },
+ "abs" to nativeFn("abs") { args -> abs(toNumber(args.getOrNull(0))) },
+ "sqrt" to nativeFn("sqrt") { args -> sqrt(toNumber(args.getOrNull(0))) },
+ "pow" to nativeFn("pow") { args -> toNumber(args.getOrNull(0)).pow(toNumber(args.getOrNull(1))) },
+ "log" to nativeFn("log") { args -> ln(toNumber(args.getOrNull(0))) },
+ "sin" to nativeFn("sin") { args -> sin(toNumber(args.getOrNull(0))) },
+ "cos" to nativeFn("cos") { args -> cos(toNumber(args.getOrNull(0))) },
+ "tan" to nativeFn("tan") { args -> tan(toNumber(args.getOrNull(0))) },
+ "max" to nativeFn("max") { args -> args.maxOfOrNull { toNumber(it) } ?: Double.NEGATIVE_INFINITY },
+ "min" to nativeFn("min") { args -> args.minOfOrNull { toNumber(it) } ?: Double.POSITIVE_INFINITY },
+ "random" to nativeFn("random") { _ -> Random.nextDouble() },
+ "trunc" to nativeFn("trunc") { args -> truncate(toNumber(args.getOrNull(0))) },
+ "log2" to nativeFn("log2") { args -> log2(toNumber(args.getOrNull(0))) },
+ "log10" to nativeFn("log10") { args -> log10(toNumber(args.getOrNull(0))) },
+ ))
+ globalScope.define("Math", mathObj)
+
+ // For String.fromCharCode
+ val stringObj = JsObject(mutableMapOf(
+ "fromCharCode" to nativeFn("fromCharCode") { args -> args.joinToString("") { toNumber(it).toInt().toChar().toString() } }
+ ))
+ globalScope.define("String", stringObj)
+
+ globalScope.define("parseInt", nativeFn("parseInt") { args ->
+ val s = toJsString(args.getOrNull(0)).trim()
+ var i = 0
+ var negative = false
+ if (i < s.length && (s[i] == '+' || s[i] == '-')) {
+ negative = s[i] == '-'
+ i++
+ }
+ var radix = args.getOrNull(1)?.let { toNumber(it).toInt() } ?: 0
+ if (radix != 0 && (radix < 2 || radix > 36)) {
+ Double.NaN
+ } else {
+ val stripHexPrefix = radix == 0 || radix == 16
+ if (radix == 0) radix = 10
+ if (stripHexPrefix && i + 1 < s.length && s[i] == '0' && (s[i + 1] == 'x' || s[i + 1] == 'X')) {
+ radix = 16
+ i += 2
+ }
+ val start = i
+ while (i < s.length && digitValue(s[i], radix) != -1) i++
+ if (i == start) {
+ Double.NaN
+ } else {
+ var value = 0.0
+ for (idx in start until i) value = value * radix + digitValue(s[idx], radix)
+ if (negative) -value else value
+ }
+ }
+ })
+ globalScope.define("parseFloat", nativeFn("parseFloat") { args -> toNumber(args.getOrNull(0)) })
+ globalScope.define("isNaN", nativeFn("isNaN") { args -> toNumber(args.getOrNull(0)).isNaN() })
+ globalScope.define("isFinite", nativeFn("isFinite") { args -> toNumber(args.getOrNull(0)).isFinite() })
+ globalScope.define("decodeURIComponent", nativeFn("decodeURIComponent") { args -> toJsString(args.getOrNull(0)).decodeUrl() })
+ globalScope.define("encodeURIComponent", nativeFn("encodeURIComponent") { args -> toJsString(args.getOrNull(0)).encodeUrl() })
+ globalScope.define("escape", nativeFn("escape") { args -> toJsString(args.getOrNull(0)).encodeUrl() })
+ globalScope.define("unescape", nativeFn("unescape") { args -> toJsString(args.getOrNull(0)).decodeUrl() })
+ // Nested eval() reuses the current budget rather than resetting it, otherwise a
+ // script could keep itself alive forever via `while(true){ eval("1") }`.
+ globalScope.define("eval", nativeFn("eval") { args -> evalInternal(toJsString(args.getOrNull(0))) })
+ globalScope.define("undefined", Unit)
+ globalScope.define("NaN", Double.NaN)
+ globalScope.define("Infinity", Double.POSITIVE_INFINITY)
+
+ globalScope.define("Array", nativeFn("Array") { args ->
+ if (args.size == 1 && args[0] is Double) JsList(MutableList((args[0] as Double).toInt()) { Unit })
+ else JsList(args.toMutableList())
+ })
+
+ globalScope.define("Object", NativeFn({ args ->
+ // Object() called as a function, wrap primitive or return object as-is
+ when (val v = args.getOrNull(0)) {
+ null, is Unit -> JsObject()
+ is JsObject -> v
+ is JsList -> v
+ else -> JsObject()
+ }
+ }, "Object", mutableMapOf(
+ "keys" to nativeFn("keys") { args ->
+ when (val o = args.getOrNull(0)) {
+ is JsObject -> JsList(o.props.keys.map { it as Any? }.toMutableList())
+ else -> JsList()
+ }
+ },
+ "values" to nativeFn("values") { args ->
+ when (val o = args.getOrNull(0)) {
+ is JsObject -> JsList(o.props.values.toMutableList())
+ else -> JsList()
+ }
+ }
+ )))
+
+ globalScope.define("Function", nativeFn("Function") { _ -> nativeFn("anonymous") { Unit } })
+ globalScope.define("Number", nativeFn("Number") { args -> toNumber(args.getOrNull(0)) })
+ globalScope.define("Boolean", nativeFn("Boolean") { args -> toBoolean(args.getOrNull(0)) })
+
+ // console.log (no-op for silence, but avoids errors)
+ val consoleObj = JsObject(mutableMapOf(
+ "log" to nativeFn("log") { _ -> Unit },
+ "error" to nativeFn("error") { _ -> Unit },
+ "warn" to nativeFn("warn") { _ -> Unit },
+ ))
+ globalScope.define("console", consoleObj)
+ }
+
+ private fun nativeFn(name: String, fn: (List) -> Any?): Any? = NativeFn(fn, name)
+
+ fun eval(code: String): Any? {
+ instructionCount = 0
+ startMark = TimeSource.Monotonic.markNow()
+ return evalInternal(code)
+ }
+
+ /** Runs [code] against the current budget, without resetting it. */
+ private fun evalInternal(code: String): Any? {
+ return try {
+ val lexer = Lexer(code)
+ val parser = Parser(lexer)
+ val stmts = parser.parseProgram()
+ var last: Any? = Unit
+ for (stmt in stmts) last = execNode(stmt, globalScope)
+ last
+ } catch (r: ReturnSignal) {
+ r.value
+ } catch (e: JsCancellationException) {
+ // CancellationException must never be swallowed. It signals that the
+ // enclosing coroutine has been cancelled and must propagate so that the
+ // coroutine can clean up correctly.
+ throw e
+ } catch (t: Throwable) {
+ logError(t)
+ Unit
+ }
+ }
+
+ /**
+ * Called on every statement execution. Throws [JsExecutionLimitExceeded] once the
+ * script has used up its time or instruction budget, or the enclosing [CoroutineScope]
+ * has been cancelled. This is what lets something like `evalJs("while(true){}")`
+ * return instead of burning the CPU forever.
+ *
+ * [JsExecutionLimitExceeded] extends [Throwable] rather than [Exception], so a JS
+ * script cannot catch it with its own try/catch block (the [TryCatch] handler in
+ * [execNode] only catches [Exception]).
+ */
+ private fun checkBudget() {
+ instructionCount++
+ if (instructionCount >= maxInstructions) {
+ throw JsExecutionLimitExceeded("script exceeded max instruction count of $maxInstructions")
+ }
+ if (scope != null && !scope.isActive) {
+ throw JsCancellationException("script cancelled: coroutine scope is no longer active")
+ }
+ // Only sample the clock every 1024 ticks. Calling elapsedNow() on every single
+ // statement would add measurable overhead to normal (non-runaway) scripts.
+ if (instructionCount and 0x3FFL == 0L && startMark.elapsedNow() >= maxExecutionTime) {
+ throw JsExecutionLimitExceeded("script exceeded max execution time of $maxExecutionTime")
+ }
+ }
+
+ fun getVar(name: String): Any? = globalScope.get(name).let { if (it is Unit) null else it }
+ fun setVar(name: String, value: Any?) = globalScope.define(name, value)
+
+ private fun execNode(node: Node, scope: Scope): Any? {
+ checkBudget()
+ return when (node) {
+ is VarDecl -> {
+ for ((name, init) in node.decls) scope.define(name, init?.let { evalExpr(it, scope) })
+ Unit
+ }
+ is ExprStmt -> evalExpr(node.expr, scope)
+ is BlockStmt -> {
+ val inner = Scope(scope)
+ var last: Any? = Unit
+ for (s in node.stmts) last = execNode(s, inner)
+ last
+ }
+ is ReturnStmt -> throw ReturnSignal(node.expr?.let { evalExpr(it, scope) })
+ is IfStmt -> {
+ if (toBoolean(evalExpr(node.test, scope))) execNode(node.cons, scope)
+ else node.alt?.let { execNode(it, scope) }
+ }
+ is WhileStmt -> {
+ try {
+ while (toBoolean(evalExpr(node.test, scope))) {
+ try { execNode(node.body, scope) } catch (_: ContinueSignal) {}
+ }
+ } catch (_: BreakSignal) {}
+ Unit
+ }
+ is ForStmt -> {
+ val inner = Scope(scope)
+ node.init?.let { execNode(it, inner) }
+ try {
+ while (node.test == null || toBoolean(evalExpr(node.test, inner))) {
+ try { execNode(node.body, inner) } catch (_: ContinueSignal) {}
+ node.update?.let { evalExpr(it, inner) }
+ }
+ } catch (_: BreakSignal) {}
+ Unit
+ }
+ is ForInStmt -> {
+ val obj = evalExpr(node.obj, scope)
+ val inner = Scope(scope)
+ try {
+ when (obj) {
+ is JsObject -> for (key in obj.props.keys) {
+ inner.define(node.decl, key)
+ try { execNode(node.body, inner) } catch (_: ContinueSignal) {}
+ }
+ is JsList -> for (i in obj.elements.indices) {
+ inner.define(node.decl, i.toDouble())
+ try { execNode(node.body, inner) } catch (_: ContinueSignal) {}
+ }
+ else -> {}
+ }
+ } catch (_: BreakSignal) {}
+ Unit
+ }
+ is TryCatch -> {
+ try {
+ for (s in node.body) execNode(s, scope)
+ } catch (ts: ThrowSignal) {
+ if (node.catchBody != null) {
+ val inner = Scope(scope)
+ if (node.catchParam != null) inner.define(node.catchParam, ts.value)
+ for (s in node.catchBody) execNode(s, inner)
+ }
+ } catch (e: JsCancellationException) {
+ // CancellationException must never be swallowed by a JS try/catch.
+ // It must propagate so withTimeout and structured concurrency
+ // work correctly.
+ throw e
+ } catch (_: Exception) {
+ // swallow other exceptions inside try (e.g. runtime errors)
+ } finally {
+ node.finallyBody?.forEach { execNode(it, scope) }
+ }
+ Unit
+ }
+ is ThrowStmt -> throw ThrowSignal(evalExpr(node.expr, scope))
+ is BreakStmt -> throw BreakSignal
+ is ContinueStmt -> throw ContinueSignal
+ else -> evalExpr(node, scope)
+ }
+ }
+
+ private fun evalExpr(node: Node, scope: Scope): Any? = when (node) {
+ is NumLit -> node.v
+ is StrLit -> node.v
+ is BoolLit -> node.v
+ NullLit -> null
+ UndefinedLit -> Unit
+ is Ident -> scope.get(node.name)
+ is ArrayLit -> JsList(node.elems.map { evalExpr(it, scope) }.toMutableList())
+ is ObjLit -> JsObject(node.pairs.associate { (k, v) -> k to evalExpr(v, scope) }.toMutableMap())
+ is FuncExpr -> {
+ val fn = JsFunction(node.name, node.params, node.body, scope)
+ if (node.name != null) scope.define(node.name, fn)
+ fn
+ }
+ is SeqExpr -> node.exprs.fold(Unit as Any?) { _, e -> evalExpr(e, scope) }
+ is TypeofExpr -> {
+ val v = try { evalExpr(node.expr, scope) } catch (_: Exception) { Unit }
+ when (v) {
+ is Unit -> "undefined"
+ null -> "object"
+ is Double -> "number"
+ is Boolean -> "boolean"
+ is String -> "string"
+ is JsFunction, is NativeFn -> "function"
+ else -> "object"
+ }
+ }
+ is UnaryExpr -> evalUnary(node, scope)
+ is BinExpr -> evalBinary(node, scope)
+ is AssignExpr -> evalAssign(node, scope)
+ is CondExpr -> if (toBoolean(evalExpr(node.test, scope))) evalExpr(node.cons, scope) else evalExpr(node.alt, scope)
+ is MemberExpr -> evalMember(node, scope)
+ is CallExpr -> evalCall(node, scope)
+ is NewExpr -> evalNew(node, scope)
+ else -> Unit
+ }
+
+ private fun evalUnary(node: UnaryExpr, scope: Scope): Any? {
+ if (node.op == "++" || node.op == "--") {
+ val delta = if (node.op == "++") 1.0 else -1.0
+ val old = toNumber(evalExpr(node.expr, scope))
+ val newVal = old + delta
+ assignTo(node.expr, newVal, scope)
+ return if (node.prefix) newVal else old
+ }
+ val v = evalExpr(node.expr, scope)
+ return when (node.op) {
+ "-" -> -toNumber(v)
+ "+" -> toNumber(v)
+ "!" -> !toBoolean(v)
+ "~" -> toNumber(v).toLong().inv().toDouble()
+ else -> Unit
+ }
+ }
+
+ private fun evalBinary(node: BinExpr, scope: Scope): Any? {
+ // Short-circuit
+ if (node.op == "&&") {
+ val l = evalExpr(node.left, scope)
+ return if (!toBoolean(l)) l else evalExpr(node.right, scope)
+ }
+ if (node.op == "||") {
+ val l = evalExpr(node.left, scope)
+ return if (toBoolean(l)) l else evalExpr(node.right, scope)
+ }
+ val l = evalExpr(node.left, scope)
+ val r = evalExpr(node.right, scope)
+ return when (node.op) {
+ "+" -> {
+ val lp = if (l is JsList || l is JsObject || l is NativeFn || l is JsFunction) toJsString(l) else l
+ val rp = if (r is JsList || r is JsObject || r is NativeFn || r is JsFunction) toJsString(r) else r
+ if (lp is String || rp is String) toJsString(lp) + toJsString(rp)
+ else toNumber(lp) + toNumber(rp)
+ }
+ "-" -> toNumber(l) - toNumber(r)
+ "*" -> toNumber(l) * toNumber(r)
+ "/" -> toNumber(l) / toNumber(r)
+ "%" -> toNumber(l) % toNumber(r)
+ "**" -> toNumber(l).pow(toNumber(r))
+ "<" -> toNumber(l) < toNumber(r)
+ "<=" -> toNumber(l) <= toNumber(r)
+ ">" -> toNumber(l) > toNumber(r)
+ ">=" -> toNumber(l) >= toNumber(r)
+ "==" -> looseEq(l, r)
+ "!=" -> !looseEq(l, r)
+ "===" -> strictEq(l, r)
+ "!==" -> !strictEq(l, r)
+ "&" -> (toNumber(l).toLong() and toNumber(r).toLong()).toDouble()
+ "|" -> (toNumber(l).toLong() or toNumber(r).toLong()).toDouble()
+ "^" -> (toNumber(l).toLong() xor toNumber(r).toLong()).toDouble()
+ "<<" -> (toNumber(l).toLong() shl toNumber(r).toInt()).toDouble()
+ ">>" -> (toNumber(l).toLong() shr toNumber(r).toInt()).toDouble()
+ ">>>" -> (toNumber(l).toInt().toLong() and 0xFFFFFFFFL ushr toNumber(r).toInt()).toDouble()
+ "instanceof" -> when (r) {
+ is NativeFn -> when (r.name) {
+ "Array" -> l is JsList
+ "Object" -> l is JsObject || l is JsList
+ "Function" -> l is JsFunction || l is NativeFn
+ "String" -> l is String
+ "Number" -> l is Double
+ "Boolean" -> l is Boolean
+ else -> false
+ }
+ is JsFunction -> l is JsObject && l.constructor === r
+ else -> false
+ }
+ "in" -> when (r) {
+ is JsObject -> toJsString(l) in r.props
+ is JsList -> toNumber(l).toInt().let { it >= 0 && it < r.elements.size }
+ else -> false
+ }
+ else -> Unit
+ }
+ }
+
+ private fun evalAssign(node: AssignExpr, scope: Scope): Any? {
+ val right = evalExpr(node.right, scope)
+ val value = if (node.op == "=") right else {
+ val left = evalExpr(node.left, scope)
+ when (node.op) {
+ "+=" -> if (left is String || right is String) toJsString(left) + toJsString(right) else toNumber(left) + toNumber(right)
+ "-=" -> toNumber(left) - toNumber(right)
+ "*=" -> toNumber(left) * toNumber(right)
+ "/=" -> toNumber(left) / toNumber(right)
+ "%=" -> toNumber(left) % toNumber(right)
+ "**=" -> toNumber(left).pow(toNumber(right))
+ else -> right
+ }
+ }
+ assignTo(node.left, value, scope)
+ return value
+ }
+
+ private fun assignTo(target: Node, value: Any?, scope: Scope) {
+ when (target) {
+ is Ident -> scope.set(target.name, value)
+ is MemberExpr -> {
+ val obj = evalExpr(target.obj, scope)
+ val key = propKey(target.prop, target.computed, scope)
+ when (obj) {
+ is JsObject -> obj.props[key] = value
+ is JsList -> {
+ val idx = key.toIntOrNull()
+ if (idx != null) obj[idx] = value
+ else if (key == "length") obj.length = toNumber(value).toInt()
+ }
+ else -> {}
+ }
+ }
+ // Anything else (e.g. `true++` or `5 = 3`) is not a valid assignment target in
+ // JS. This would be a SyntaxError/ReferenceError there, so we fail the same way
+ // here rather than silently doing nothing.
+ else -> throw RuntimeException("Invalid assignment target: $target")
+ }
+ }
+
+ /** Resolves a MemberExpr property node to its string key without unsafe casts. */
+ private fun propKey(prop: Node, computed: Boolean, scope: Scope): String =
+ if (computed) toJsString(evalExpr(prop, scope))
+ else (prop as? StrLit)?.v ?: toJsString(evalExpr(prop, scope))
+
+ private fun evalMember(node: MemberExpr, scope: Scope): Any? {
+ val obj = evalExpr(node.obj, scope)
+ val key = propKey(node.prop, node.computed, scope)
+ return getMember(obj, key)
+ }
+
+ private fun getMember(obj: Any?, key: String): Any? = when (obj) {
+ is NativeFn -> obj.props[key] ?: Unit
+ is JsObject -> obj.props[key] ?: Unit
+ is JsList -> when (key) {
+ "length" -> obj.length.toDouble()
+ "join" -> nativeFn("join") { args ->
+ val sep = args.getOrNull(0)?.let { toJsString(it) } ?: ","
+ obj.elements.joinToString(sep) { joinElement(it) }
+ }
+ "reverse" -> nativeFn("reverse") { _ -> obj.elements.reverse(); obj }
+ "push" -> nativeFn("push") { args -> args.forEach { obj.elements.add(it) }; obj.elements.size.toDouble() }
+ "pop" -> nativeFn("pop") { _ -> if (obj.elements.isEmpty()) Unit else obj.elements.removeAt(obj.elements.size - 1) }
+ "shift" -> nativeFn("shift") { _ -> if (obj.elements.isEmpty()) Unit else obj.elements.removeAt(0) }
+ "unshift" -> nativeFn("unshift") { args -> args.reversed().forEach { obj.elements.add(0, it) }; obj.elements.size.toDouble() }
+ "slice" -> nativeFn("slice") { args ->
+ val start = args.getOrNull(0)?.let { toNumber(it).toInt() } ?: 0
+ val end = args.getOrNull(1)?.let { toNumber(it).toInt() } ?: obj.elements.size
+ val s = if (start < 0) maxOf(0, obj.elements.size + start) else minOf(start, obj.elements.size)
+ val e = if (end < 0) maxOf(0, obj.elements.size + end) else minOf(end, obj.elements.size)
+ JsList(obj.elements.subList(maxOf(0, s), maxOf(s, e)).toMutableList())
+ }
+ "splice" -> nativeFn("splice") { args ->
+ val start = args.getOrNull(0)?.let { toNumber(it).toInt() }?.let { if (it < 0) maxOf(0, obj.elements.size + it) else minOf(it, obj.elements.size) } ?: 0
+ val deleteCount = args.getOrNull(1)?.let { toNumber(it).toInt() }?.coerceIn(0, obj.elements.size - start) ?: (obj.elements.size - start)
+ val removed = JsList(obj.elements.subList(start, start + deleteCount).toMutableList())
+ repeat(deleteCount) { obj.elements.removeAt(start) }
+ args.drop(2).forEachIndexed { i, v -> obj.elements.add(start + i, v) }
+ removed
+ }
+ "indexOf" -> nativeFn("indexOf") { args ->
+ val v = args.getOrNull(0); val start = args.getOrNull(1)?.let { toNumber(it).toInt() } ?: 0
+ obj.elements.indexOfFirst { strictEq(it, v) }.let { if (it < start) -1.0 else it.toDouble() }
+ }
+ "map" -> nativeFn("map") { args ->
+ val fn = args.getOrNull(0)
+ JsList(obj.elements.mapIndexed { i, v -> callAny(fn, listOf(v, i.toDouble(), obj), null) }.toMutableList())
+ }
+ "filter" -> nativeFn("filter") { args ->
+ val fn = args.getOrNull(0)
+ JsList(obj.elements.filterIndexed { i, v -> toBoolean(callAny(fn, listOf(v, i.toDouble(), obj), null)) }.toMutableList())
+ }
+ "forEach" -> nativeFn("forEach") { args ->
+ val fn = args.getOrNull(0)
+ obj.elements.forEachIndexed { i, v -> callAny(fn, listOf(v, i.toDouble(), obj), null) }
+ Unit
+ }
+ "reduce" -> nativeFn("reduce") { args ->
+ val fn = args.getOrNull(0)
+ var acc: Any? = if (args.size > 1) args[1] else obj.elements.firstOrNull() ?: Unit
+ val startIdx = if (args.size > 1) 0 else 1
+ for (i in startIdx until obj.elements.size) acc = callAny(fn, listOf(acc, obj.elements[i], i.toDouble(), obj), null)
+ acc
+ }
+ "concat" -> nativeFn("concat") { args ->
+ val result = JsList(obj.elements.toMutableList())
+ args.forEach { a -> when (a) { is JsList -> result.elements.addAll(a.elements); else -> result.elements.add(a) } }
+ result
+ }
+ "find" -> nativeFn("find") { args ->
+ val fn = args.getOrNull(0)
+ obj.elements.firstOrNull { toBoolean(callAny(fn, listOf(it), null)) } ?: Unit
+ }
+ "some" -> nativeFn("some") { args ->
+ val fn = args.getOrNull(0)
+ obj.elements.any { toBoolean(callAny(fn, listOf(it), null)) }
+ }
+ "every" -> nativeFn("every") { args ->
+ val fn = args.getOrNull(0)
+ obj.elements.all { toBoolean(callAny(fn, listOf(it), null)) }
+ }
+ "sort" -> nativeFn("sort") { args ->
+ val fn = args.getOrNull(0)
+ if (fn == null) obj.elements.sortWith { a, b -> toJsString(a).compareTo(toJsString(b)) }
+ else obj.elements.sortWith { a, b -> toNumber(callAny(fn, listOf(a, b), null)).toInt() }
+ obj
+ }
+ "includes" -> nativeFn("includes") { args -> obj.elements.any { looseEq(it, args.getOrNull(0)) } }
+ "toString" -> nativeFn("toString") { _ -> obj.elements.joinToString(",") { joinElement(it) } }
+ "flat" -> nativeFn("flat") { _ ->
+ val result = JsList()
+ obj.elements.forEach { if (it is JsList) result.elements.addAll(it.elements) else result.elements.add(it) }
+ result
+ }
+ else -> key.toIntOrNull()?.let { obj[it] } ?: Unit
+ }
+ is String -> when (key) {
+ "length" -> obj.length.toDouble()
+ "split" -> nativeFn("split") { args ->
+ val sep = args.getOrNull(0)
+ when {
+ sep == null || sep is Unit -> JsList(mutableListOf(obj))
+ sep is String && sep.isEmpty() -> JsList(obj.map { it.toString() as Any? }.toMutableList())
+ sep is String -> JsList(obj.split(sep).map { it as Any? }.toMutableList())
+ else -> JsList(obj.split(toJsString(sep)).map { it as Any? }.toMutableList())
+ }
+ }
+ "join" -> nativeFn("join") { args -> obj } // strings don't have join but just in case
+ "replace" -> nativeFn("replace") { args ->
+ val from = args.getOrNull(0); val to = toJsString(args.getOrNull(1))
+ when (from) {
+ is String -> obj.replaceFirst(from, to)
+ else -> obj.replace(toJsString(from), to)
+ }
+ }
+ "replaceAll" -> nativeFn("replaceAll") { args ->
+ val from = args.getOrNull(0); val to = toJsString(args.getOrNull(1))
+ obj.replace(toJsString(from), to)
+ }
+ "indexOf" -> nativeFn("indexOf") { args -> obj.indexOf(toJsString(args.getOrNull(0))).toDouble() }
+ "lastIndexOf" -> nativeFn("lastIndexOf") { args -> obj.lastIndexOf(toJsString(args.getOrNull(0))).toDouble() }
+ "includes" -> nativeFn("includes") { args -> obj.contains(toJsString(args.getOrNull(0))) }
+ "startsWith" -> nativeFn("startsWith") { args -> obj.startsWith(toJsString(args.getOrNull(0))) }
+ "endsWith" -> nativeFn("endsWith") { args -> obj.endsWith(toJsString(args.getOrNull(0))) }
+ "slice" -> nativeFn("slice") { args ->
+ val start = args.getOrNull(0)?.let { toNumber(it).toInt() }?.let { if (it < 0) maxOf(0, obj.length + it) else minOf(it, obj.length) } ?: 0
+ val end = args.getOrNull(1)?.let { toNumber(it).toInt() }?.let { if (it < 0) maxOf(0, obj.length + it) else minOf(it, obj.length) } ?: obj.length
+ if (end <= start) "" else obj.substring(start, end)
+ }
+ "substr" -> nativeFn("substr") { args ->
+ val start = args.getOrNull(0)?.let { toNumber(it).toInt() }?.let { if (it < 0) maxOf(0, obj.length + it) else minOf(it, obj.length) } ?: 0
+ val len = args.getOrNull(1)?.let { toNumber(it).toInt() } ?: (obj.length - start)
+ if (len <= 0) "" else obj.substring(start, minOf(start + len, obj.length))
+ }
+ "substring" -> nativeFn("substring") { args ->
+ val a = args.getOrNull(0)?.let { toNumber(it).toInt().coerceIn(0, obj.length) } ?: 0
+ val b = args.getOrNull(1)?.let { toNumber(it).toInt().coerceIn(0, obj.length) } ?: obj.length
+ obj.substring(minOf(a, b), maxOf(a, b))
+ }
+ "charAt" -> nativeFn("charAt") { args ->
+ val i = args.getOrNull(0)?.let { toNumber(it).toInt() } ?: 0
+ if (i < 0 || i >= obj.length) "" else obj[i].toString()
+ }
+ "charCodeAt" -> nativeFn("charCodeAt") { args ->
+ val i = args.getOrNull(0)?.let { toNumber(it).toInt() } ?: 0
+ if (i < 0 || i >= obj.length) Double.NaN else obj[i].code.toDouble()
+ }
+ "codePointAt" -> nativeFn("codePointAt") { args ->
+ val i = args.getOrNull(0)?.let { toNumber(it).toInt() } ?: 0
+ if (i < 0 || i >= obj.length) Double.NaN else obj[i].code.toDouble()
+ }
+ "toUpperCase", "toLocaleUpperCase" -> nativeFn("toUpperCase") { _ -> obj.uppercase() }
+ "toLowerCase", "toLocaleLowerCase" -> nativeFn("toLowerCase") { _ -> obj.lowercase() }
+ "trim" -> nativeFn("trim") { _ -> obj.trim() }
+ "trimStart", "trimLeft" -> nativeFn("trimStart") { _ -> obj.trimStart() }
+ "trimEnd", "trimRight" -> nativeFn("trimEnd") { _ -> obj.trimEnd() }
+ "repeat" -> nativeFn("repeat") { args -> obj.repeat(toNumber(args.getOrNull(0)).toInt().coerceAtLeast(0)) }
+ "padStart" -> nativeFn("padStart") { args ->
+ val len = toNumber(args.getOrNull(0)).toInt(); val pad = args.getOrNull(1)?.let { toJsString(it) } ?: " "
+ if (obj.length >= len) obj else (pad.repeat(len) + obj).takeLast(len)
+ }
+ "padEnd" -> nativeFn("padEnd") { args ->
+ val len = toNumber(args.getOrNull(0)).toInt(); val pad = args.getOrNull(1)?.let { toJsString(it) } ?: " "
+ if (obj.length >= len) obj else (obj + pad.repeat(len)).take(len)
+ }
+ "toString" -> nativeFn("toString") { _ -> obj }
+ "valueOf" -> nativeFn("valueOf") { _ -> obj }
+ "match" -> nativeFn("match") { args ->
+ val pattern = toJsString(args.getOrNull(0))
+ try {
+ val result = Regex(pattern).find(obj)
+ if (result == null) null
+ else JsList(result.groupValues.map { it as Any? }.toMutableList())
+ } catch (_: Exception) { null }
+ }
+ else -> key.toIntOrNull()?.let {
+ if (it >= 0 && it < obj.length) obj[it].toString() else Unit
+ } ?: Unit
+ }
+ is Double -> when (key) {
+ "toString" -> nativeFn("toString") { args ->
+ val radix = args.getOrNull(0)?.let { toNumber(it).toInt() } ?: 10
+ if (radix == 10) toJsString(obj) else obj.toLong().toString(radix)
+ }
+ "toFixed" -> nativeFn("toFixed") { args ->
+ val digits = (args.getOrNull(0)?.let { toNumber(it).toInt() } ?: 0).coerceIn(0, 20)
+ val factor = 10.0.pow(digits)
+ val rounded = round(obj * factor) / factor
+ val sign = if (rounded < 0) "-" else ""
+ val absVal = abs(rounded)
+ val intPart = absVal.toLong()
+ val fracPart = round((absVal - intPart) * factor).toLong()
+ val fracStr = fracPart.toString().padStart(digits, '0')
+ if (digits == 0) "$sign$intPart" else "$sign$intPart.$fracStr"
+ }
+ else -> Unit
+ }
+ else -> Unit
+ }
+
+ private fun evalCall(node: CallExpr, scope: Scope): Any? {
+ val (callee, thisVal) = resolveCallee(node.callee, scope)
+ val args = node.args.map { evalExpr(it, scope) }
+ return callAny(callee, args, thisVal)
+ }
+
+ private fun resolveCallee(calleeNode: Node, scope: Scope): Pair {
+ return if (calleeNode is MemberExpr) {
+ val obj = evalExpr(calleeNode.obj, scope)
+ val key = propKey(calleeNode.prop, calleeNode.computed, scope)
+ getMember(obj, key) to obj
+ } else {
+ evalExpr(calleeNode, scope) to null
+ }
+ }
+
+ private fun evalNew(node: NewExpr, scope: Scope): Any? {
+ val callee = evalExpr(node.callee, scope)
+ val args = node.args.map { evalExpr(it, scope) }
+ // NativeFn constructors (e.g. Array) return their value directly
+ if (callee is NativeFn) return callee.fn(args)
+ val thisVal = JsObject(constructor = callee as? JsFunction)
+ val result = callAny(callee, args, thisVal)
+ // JS 'new' returns the constructed object unless the constructor explicitly returns an object
+ return if (result is JsObject) result else thisVal
+ }
+
+ private fun callAny(callee: Any?, args: List, thisVal: Any?): Any? = when (callee) {
+ is NativeFn -> callee.fn(args)
+ is JsFunction -> {
+ val fnScope = Scope(callee.closure)
+ fnScope.define("this", thisVal)
+ fnScope.define("arguments", JsList(args.toMutableList()))
+ callee.params.forEachIndexed { i, p -> fnScope.define(p, args.getOrElse(i) { Unit }) }
+ try {
+ var last: Any? = Unit
+ for (s in callee.body) last = execNode(s, fnScope)
+ last
+ } catch (r: ReturnSignal) {
+ r.value
+ }
+ }
+ else -> Unit
+ }
+}
+
+// Wrapper so we can store Kotlin lambdas as "callable" values
+private class NativeFn(val fn: (List) -> Any?, val name: String, val props: MutableMap = mutableMapOf()) {
+ override fun toString(): String = "function $name() { [native code] }"
+}
diff --git a/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/JsInterpreterTest.kt b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/JsInterpreterTest.kt
new file mode 100644
index 000000000..182ea6c95
--- /dev/null
+++ b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/JsInterpreterTest.kt
@@ -0,0 +1,2410 @@
+package com.lagradost.cloudstream3.utils
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withTimeout
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlin.math.E
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.TimeSource
+
+class JsInterpreterTest {
+
+ private fun bool(code: String, variable: String? = null): Boolean = evalJsInternal(code, variable) as? Boolean ?: false
+ private fun num(code: String, variable: String? = null): Double = evalJsInternal(code, variable) as? Double ?: Double.NaN
+ private fun str(code: String, variable: String? = null): String = jsValueToString(evalJsInternal(code, variable))
+
+ private fun assertApprox(expected: Double, actual: Double, tol: Double = 1e-9) {
+ assertTrue(abs(actual - expected) <= tol, "Expected $expected ± $tol but was $actual")
+ }
+
+ @Test
+ fun integerLiteral() {
+ assertEquals(42.0, num("42"))
+ }
+
+ @Test
+ fun negativeLiteral() {
+ assertEquals(-7.0, num("-7"))
+ }
+
+ @Test
+ fun floatLiteral() {
+ assertApprox(3.14, num("3.14"))
+ }
+
+ @Test
+ fun hexLiteral() {
+ assertEquals(255.0, num("0xff"))
+ }
+
+ @Test
+ fun stringLiteralDouble() {
+ assertEquals("hi", str("\"hi\""))
+ }
+
+ @Test
+ fun stringLiteralSingle() {
+ assertEquals("hi", str("'hi'"))
+ }
+
+ @Test
+ fun templateLiteral() {
+ assertEquals("hi", str("`hi`"))
+ }
+
+ @Test
+ fun booleanTrue() {
+ assertTrue(bool("true"))
+ }
+
+ @Test
+ fun booleanFalse() {
+ assertFalse(bool("false"))
+ }
+
+ @Test
+ fun nullLiteral() {
+ assertNull(evalJsInternal("null"))
+ }
+
+ @Test
+ fun undefinedLiteral() {
+ assertEquals(Unit, evalJsInternal("undefined"))
+ }
+
+ @Test
+ fun addition() {
+ assertEquals(5.0, num("2+3"))
+ }
+
+ @Test
+ fun subtraction() {
+ assertEquals(1.0, num("3-2"))
+ }
+
+ @Test
+ fun multiplication() {
+ assertEquals(6.0, num("2*3"))
+ }
+
+ @Test
+ fun division() {
+ assertEquals(2.5, num("5/2"))
+ }
+
+ @Test
+ fun modulo() {
+ assertEquals(1.0, num("7%3"))
+ }
+
+ @Test
+ fun operatorPrecedence() {
+ assertEquals(7.0, num("1+2*3"))
+ }
+
+ @Test
+ fun parenthesesOverride() {
+ assertEquals(9.0, num("(1+2)*3"))
+ }
+
+ @Test
+ fun unaryMinus() {
+ assertEquals(-5.0, num("-(2+3)"))
+ }
+
+ @Test
+ fun unaryPlusCoerces() {
+ assertEquals(3.0, num("+'3'"))
+ }
+
+ @Test
+ fun stringMinusNumberCoercesToNumber() {
+ // "3" - 1 => 2 (JS coerces string to number for subtraction)
+ assertEquals(2.0, num("'3'-1"))
+ }
+
+ @Test
+ fun stringTimesStringCoercesToNumber() {
+ // "3" * "4" => 12
+ assertEquals(12.0, num("'3'*'4'"))
+ }
+
+ @Test
+ fun nanArithmetic() {
+ // NaN + 1 => NaN
+ assertTrue(num("NaN+1").isNaN())
+ }
+
+ @Test
+ fun infinityPositive() {
+ assertTrue(num("Infinity").isInfinite() && num("Infinity") > 0)
+ }
+
+ @Test
+ fun infinityArithmetic() {
+ assertTrue(num("Infinity+1").isInfinite())
+ }
+
+ @Test
+ fun divisionByZeroIsInfinity() {
+ assertTrue(num("1/0").isInfinite() && num("1/0") > 0)
+ }
+
+ @Test
+ fun bitwiseAnd() {
+ assertEquals(2.0, num("6&3"))
+ }
+
+ @Test
+ fun bitwiseOr() {
+ assertEquals(7.0, num("5|3"))
+ }
+
+ @Test
+ fun bitwiseXor() {
+ assertEquals(6.0, num("5^3"))
+ }
+
+ @Test
+ fun bitwiseNot() {
+ assertEquals(-6.0, num("~5"))
+ }
+
+ @Test
+ fun bitwiseNotOnNegative() {
+ assertEquals(4.0, num("~(-5)"))
+ }
+
+ @Test
+ fun bitwiseNotTruncatesFloat() {
+ // ~3.9 == ~3 == -4
+ assertEquals(-4.0, num("~3.9"))
+ }
+
+ @Test
+ fun leftShift() {
+ assertEquals(20.0, num("5<<2"))
+ }
+
+ @Test
+ fun rightShift() {
+ assertEquals(1.0, num("5>>2"))
+ }
+
+ @Test
+ fun unsignedRightShift() {
+ assertEquals(1.0, num("5>>>2"))
+ }
+
+ @Test
+ fun unsignedRightShiftOnNegative() {
+ // (-1 >>> 0) in JS == 4294967295 (treats as unsigned 32-bit)
+ assertEquals(4294967295.0, num("-1>>>0"))
+ }
+
+ @Test
+ fun bitwiseOrWithZeroTruncatesFloat() {
+ // 3.9|0 == 3 (common JS truncation idiom)
+ assertEquals(3.0, num("3.9|0"))
+ }
+
+ @Test
+ fun lessThanTrue() {
+ assertTrue(bool("1<2"))
+ }
+
+ @Test
+ fun lessThanFalse() {
+ assertFalse(bool("2<1"))
+ }
+
+ @Test
+ fun greaterThanTrue() {
+ assertTrue(bool("2>1"))
+ }
+
+ @Test
+ fun lessThanOrEqualEqual() {
+ assertTrue(bool("2<=2"))
+ }
+
+ @Test
+ fun greaterThanOrEqualEqual() {
+ assertTrue(bool("2>=2"))
+ }
+
+ @Test
+ fun looseEqNumberString() {
+ assertTrue(bool("1=='1'"))
+ }
+
+ @Test
+ fun looseEqNullUndefined() {
+ // null == undefined is true in JS
+ assertTrue(bool("null==undefined"))
+ }
+
+ @Test
+ fun looseEqNullNotZero() {
+ // null == 0 is false in JS
+ assertFalse(bool("null==0"))
+ }
+
+ @Test
+ fun looseNeqWorks() {
+ assertTrue(bool("1!='2'"))
+ }
+
+ @Test
+ fun strictEqSameType() {
+ assertTrue(bool("1===1"))
+ }
+
+ @Test
+ fun strictEqDifferentType() {
+ assertFalse(bool("1==='1'"))
+ }
+
+ @Test
+ fun strictNeq() {
+ assertTrue(bool("1!=='1'"))
+ }
+
+ @Test
+ fun instanceofArrayTrue() {
+ assertTrue(bool("[] instanceof Array"))
+ }
+
+ @Test
+ fun instanceofArrayFalseForObject() {
+ assertFalse(bool("({}) instanceof Array"))
+ }
+
+ @Test
+ fun instanceofObjectTrueForPlainObject() {
+ assertTrue(bool("({}) instanceof Object"))
+ }
+
+ @Test
+ fun instanceofObjectTrueForArray() {
+ // Arrays are objects in JS
+ assertTrue(bool("[] instanceof Object"))
+ }
+
+ @Test
+ fun instanceofFunctionTrue() {
+ assertTrue(bool("(function(){}) instanceof Function"))
+ }
+
+ @Test
+ fun instanceofFunctionFalseForArray() {
+ assertFalse(bool("[] instanceof Function"))
+ }
+
+ @Test
+ fun instanceofUserDefinedConstructorTrue() {
+ assertTrue(bool("function Dog(){} var d = new Dog(); d instanceof Dog"))
+ }
+
+ @Test
+ fun instanceofUserDefinedConstructorFalseForOtherClass() {
+ assertFalse(bool("function Cat(){} function Dog(){} var d = new Dog(); d instanceof Cat"))
+ }
+
+ @Test
+ fun instanceofUserDefinedConstructorFalseForPlainObject() {
+ assertFalse(bool("function Dog(){} ({}) instanceof Dog"))
+ }
+
+ @Test
+ fun instanceofUserDefinedConstructorWithProperties() {
+ val code = """
+ function Point(x, y) { this.x = x; this.y = y; }
+ var p = new Point(1, 2);
+ (p instanceof Point) && p.x === 1 && p.y === 2
+ """.trimIndent()
+ assertTrue(bool(code))
+ }
+
+ @Test
+ fun inOperatorOnObject() {
+ assertTrue(bool("'a' in {a:1}"))
+ }
+
+ @Test
+ fun inOperatorOnObjectMissing() {
+ assertFalse(bool("'z' in {a:1}"))
+ }
+
+ @Test
+ fun inOperatorOnArray() {
+ assertTrue(bool("0 in [10,20]"))
+ }
+
+ @Test
+ fun logicalAndRightWhenLeftTruthy() {
+ assertEquals(2.0, num("1&&2"))
+ }
+
+ @Test
+ fun logicalAndLeftWhenLeftFalsy() {
+ assertEquals(0.0, num("0&&2"))
+ }
+
+ @Test
+ fun logicalOrLeftWhenTruthy() {
+ assertEquals(1.0, num("1||2"))
+ }
+
+ @Test
+ fun logicalOrRightWhenLeftFalsy() {
+ assertEquals(2.0, num("0||2"))
+ }
+
+ @Test
+ fun logicalNot() {
+ assertTrue(bool("!false"))
+ }
+
+ @Test
+ fun logicalNotOnZero() {
+ assertTrue(bool("!0"))
+ }
+
+ @Test
+ fun logicalNotOnEmptyString() {
+ assertTrue(bool("!''"))
+ }
+
+ @Test
+ fun logicalNotOnNonEmptyString() {
+ assertFalse(bool("!'x'"))
+ }
+
+ @Test
+ fun ternaryTrueBranch() {
+ assertEquals(1.0, num("true?1:2"))
+ }
+
+ @Test
+ fun ternaryFalseBranch() {
+ assertEquals(2.0, num("false?1:2"))
+ }
+
+ @Test
+ fun nestedTernary() {
+ // 1>2 ? 'a' : 3>2 ? 'b' : 'c' => 'b'
+ assertEquals("b", str("1>2?'a':3>2?'b':'c'"))
+ }
+
+ @Test
+ fun varDeclarationAndRead() {
+ assertEquals(10.0, num("var x=10; x"))
+ }
+
+ @Test
+ fun letDeclaration() {
+ assertEquals("hello", str("let s='hello'; s"))
+ }
+
+ @Test
+ fun constDeclaration() {
+ assertApprox(3.14, num("const PI=3.14; PI"))
+ }
+
+ @Test
+ fun multiVarDeclaration() {
+ assertEquals(3.0, num("var a=1, b=2; a+b"))
+ }
+
+ @Test
+ fun assignmentPlusEquals() {
+ assertEquals(15.0, num("var x=10; x+=5; x"))
+ }
+
+ @Test
+ fun assignmentMinusEquals() {
+ assertEquals(5.0, num("var x=10; x-=5; x"))
+ }
+
+ @Test
+ fun assignmentTimesEquals() {
+ assertEquals(20.0, num("var x=4; x*=5; x"))
+ }
+
+ @Test
+ fun assignmentDivideEquals() {
+ assertEquals(5.0, num("var x=10; x/=2; x"))
+ }
+
+ @Test
+ fun assignmentModuloEquals() {
+ assertEquals(1.0, num("var x=7; x%=3; x"))
+ }
+
+ @Test
+ fun prefixIncrement() {
+ assertEquals(6.0, num("var x=5; ++x"))
+ }
+
+ @Test
+ fun postfixIncrementReturnsOldValue() {
+ assertEquals(5.0, num("var x=5; x++"))
+ }
+
+ @Test
+ fun postfixIncrementMutatesVariable() {
+ assertEquals(6.0, num("var x=5; x++; x"))
+ }
+
+ @Test
+ fun prefixDecrement() {
+ assertEquals(4.0, num("var x=5; --x"))
+ }
+
+ @Test
+ fun postfixDecrement() {
+ assertEquals(5.0, num("var x=5; x--"))
+ }
+
+ @Test
+ fun postfixDecrementMutates() {
+ assertEquals(4.0, num("var x=5; x--; x"))
+ }
+
+ @Test
+ fun commaOperatorReturnsLast() {
+ // Sequence/comma expression: (1, 2, 3) => 3
+ assertEquals(3.0, num("(1,2,3)"))
+ }
+
+ @Test
+ fun typeofNumber() {
+ assertEquals("number", str("typeof 42"))
+ }
+
+ @Test
+ fun typeofString() {
+ assertEquals("string", str("typeof 'x'"))
+ }
+
+ @Test
+ fun typeofBoolean() {
+ assertEquals("boolean", str("typeof true"))
+ }
+
+ @Test
+ fun typeofUndefined() {
+ assertEquals("undefined", str("typeof undefined"))
+ }
+
+ @Test
+ fun typeofFunction() {
+ assertEquals("function", str("typeof function(){}"))
+ }
+
+ @Test
+ fun typeofNull() {
+ // typeof null === "object" in JS
+ assertEquals("object", str("typeof null"))
+ }
+
+ @Test
+ fun typeofObject() {
+ assertEquals("object", str("typeof {}"))
+ }
+
+ @Test
+ fun typeofArray() {
+ assertEquals("object", str("typeof []"))
+ }
+
+ @Test
+ fun typeofUndeclaredVariable() {
+ // typeof on an undeclared variable should not throw, returns "undefined"
+ assertEquals("undefined", str("typeof neverDeclaredXyz"))
+ }
+
+ @Test
+ fun voidOperator() {
+ assertEquals(Unit, evalJsInternal("void 0"))
+ }
+
+ @Test
+ fun voidOperatorOnExpression() {
+ assertEquals(Unit, evalJsInternal("void (1+2)"))
+ }
+
+ @Test
+ fun stringConcatenation() {
+ assertEquals("ab", str("'a'+'b'"))
+ }
+
+ @Test
+ fun numberPlusStringCoercesToString() {
+ assertEquals("1x", str("1+'x'"))
+ }
+
+ @Test
+ fun stringLength() {
+ assertEquals(5.0, num("'hello'.length"))
+ }
+
+ @Test
+ fun stringCharAt() {
+ assertEquals("e", str("'hello'.charAt(1)"))
+ }
+
+ @Test
+ fun stringCharAtOutOfRange() {
+ assertEquals("", str("'hello'.charAt(99)"))
+ }
+
+ @Test
+ fun stringCharCodeAt() {
+ assertEquals(104.0, num("'hello'.charCodeAt(0)"))
+ }
+
+ @Test
+ fun stringCodePointAt() {
+ assertEquals(104.0, num("'hello'.codePointAt(0)"))
+ }
+
+ @Test
+ fun stringBracketIndexing() {
+ // 'hello'[1] === 'e'
+ assertEquals("e", str("'hello'[1]"))
+ }
+
+ @Test
+ fun stringBracketIndexingFirst() {
+ assertEquals("h", str("'hello'[0]"))
+ }
+
+ @Test
+ fun stringIndexOfFound() {
+ assertEquals(1.0, num("'hello'.indexOf('e')"))
+ }
+
+ @Test
+ fun stringIndexOfNotFound() {
+ assertEquals(-1.0, num("'hello'.indexOf('z')"))
+ }
+
+ @Test
+ fun stringLastIndexOf() {
+ assertEquals(3.0, num("'abcabc'.lastIndexOf('a')"))
+ }
+
+ @Test
+ fun stringLastIndexOfNotFound() {
+ assertEquals(-1.0, num("'hello'.lastIndexOf('z')"))
+ }
+
+ @Test
+ fun stringSlice() {
+ assertEquals("ell", str("'hello'.slice(1,4)"))
+ }
+
+ @Test
+ fun stringSliceNegativeIndex() {
+ assertEquals("lo", str("'hello'.slice(-2)"))
+ }
+
+ @Test
+ fun stringSliceNegativeEnd() {
+ assertEquals("hel", str("'hello'.slice(0,-2)"))
+ }
+
+ @Test
+ fun stringSubstr() {
+ assertEquals("ell", str("'hello'.substr(1,3)"))
+ }
+
+ @Test
+ fun stringSubstring() {
+ assertEquals("ell", str("'hello'.substring(1,4)"))
+ }
+
+ @Test
+ fun stringSubstringSwapsArgs() {
+ // substring swaps start/end if start > end
+ assertEquals("ell", str("'hello'.substring(4,1)"))
+ }
+
+ @Test
+ fun stringSplitAndJoin() {
+ assertEquals("a-b-c", str("'a|b|c'.split('|').join('-')"))
+ }
+
+ @Test
+ fun stringSplitEmptySepGivesChars() {
+ assertEquals("h,e,l,l,o", str("'hello'.split('').join(',')"))
+ }
+
+ @Test
+ fun stringReplaceFirstOccurrence() {
+ assertEquals("xbc", str("'abc'.replace('a','x')"))
+ }
+
+ @Test
+ fun stringReplaceAll() {
+ assertEquals("xbxbxb", str("'ababab'.replace('a','x').replace('a','x').replace('a','x')"))
+ }
+
+ @Test
+ fun stringReplaceAllMethod() {
+ assertEquals("xbxbxb", str("'ababab'.replaceAll('a','x')"))
+ }
+
+ @Test
+ fun stringToUpperCase() {
+ assertEquals("HELLO", str("'hello'.toUpperCase()"))
+ }
+
+ @Test
+ fun stringToLowerCase() {
+ assertEquals("hello", str("'HELLO'.toLowerCase()"))
+ }
+
+ @Test
+ fun stringTrim() {
+ assertEquals("hi", str("' hi '.trim()"))
+ }
+
+ @Test
+ fun stringTrimStart() {
+ assertEquals("hi ", str("' hi '.trimStart()"))
+ }
+
+ @Test
+ fun stringTrimEnd() {
+ assertEquals(" hi", str("' hi '.trimEnd()"))
+ }
+
+ @Test
+ fun stringRepeat() {
+ assertEquals("aaa", str("'a'.repeat(3)"))
+ }
+
+ @Test
+ fun stringRepeatZero() {
+ assertEquals("", str("'a'.repeat(0)"))
+ }
+
+ @Test
+ fun stringPadStart() {
+ assertEquals("005", str("'5'.padStart(3,'0')"))
+ }
+
+ @Test
+ fun stringPadEnd() {
+ assertEquals("500", str("'5'.padEnd(3,'0')"))
+ }
+
+ @Test
+ fun stringPadStartNoOpWhenLongEnough() {
+ assertEquals("hello", str("'hello'.padStart(3,'0')"))
+ }
+
+ @Test
+ fun stringIncludes() {
+ assertTrue(bool("'hello'.includes('ell')"))
+ }
+
+ @Test
+ fun stringIncludesFalse() {
+ assertFalse(bool("'hello'.includes('xyz')"))
+ }
+
+ @Test
+ fun stringStartsWith() {
+ assertTrue(bool("'hello'.startsWith('hel')"))
+ }
+
+ @Test
+ fun stringEndsWith() {
+ assertTrue(bool("'hello'.endsWith('llo')"))
+ }
+
+ @Test
+ fun stringFromCharCode() {
+ assertEquals("A", str("String.fromCharCode(65)"))
+ }
+
+ @Test
+ fun stringFromCharCodeMultiple() {
+ assertEquals("Hi", str("String.fromCharCode(72,105)"))
+ }
+
+ @Test
+ fun stringToString() {
+ assertEquals("hello", str("'hello'.toString()"))
+ }
+
+ @Test
+ fun stringMatch() {
+ // match returns array of groups; [0] is the full match
+ assertEquals("ell", str("'hello'.match('ell')[0]"))
+ }
+
+ @Test
+ fun stringMatchNoMatch() {
+ assertNull(evalJsInternal("'hello'.match('xyz')"))
+ }
+
+ @Test
+ fun numberToStringRadix16() {
+ assertEquals("ff", str("(255).toString(16)"))
+ }
+
+ @Test
+ fun numberToStringRadix2() {
+ assertEquals("1010", str("(10).toString(2)"))
+ }
+
+ @Test
+ fun numberToFixed() {
+ assertEquals("3.14", str("(3.14159).toFixed(2)"))
+ }
+
+ @Test
+ fun toFixedZeroDigits() {
+ assertEquals("4", str("(3.6).toFixed(0)"))
+ }
+
+ @Test
+ fun toFixedTwoDigits() {
+ assertEquals("3.14", str("(3.14159).toFixed(2)"))
+ }
+
+ @Test
+ fun toFixedPadsWithZeroes() {
+ assertEquals("3.10", str("(3.1).toFixed(2)"))
+ }
+
+ @Test
+ fun toFixedNegativeNumber() {
+ assertEquals("-3.14", str("(-3.14159).toFixed(2)"))
+ }
+
+ @Test
+ fun toFixedNegativeBetweenZeroAndMinusOne() {
+ assertEquals("-0.50", str("(-0.5).toFixed(2)"))
+ }
+
+ @Test
+ fun toFixedWholeNumber() {
+ assertEquals("5.00", str("(5).toFixed(2)"))
+ }
+
+ @Test
+ fun toFixedZeroValue() {
+ assertEquals("0.00", str("(0).toFixed(2)"))
+ }
+
+ @Test
+ fun arrayLiteralAndLength() {
+ assertEquals(3.0, num("[1,2,3].length"))
+ }
+
+ @Test
+ fun arrayIndexAccess() {
+ assertEquals(2.0, num("[1,2,3][1]"))
+ }
+
+ @Test
+ fun arrayJoin() {
+ assertEquals("1,2,3", str("[1,2,3].join(',')"))
+ }
+
+ @Test
+ fun arrayJoinDefaultSep() {
+ assertEquals("1,2,3", str("[1,2,3].join()"))
+ }
+
+ @Test
+ fun arrayReverse() {
+ assertEquals("3,2,1", str("[1,2,3].reverse().join(',')"))
+ }
+
+ @Test
+ fun arrayPushReturnsNewLength() {
+ assertEquals(4.0, num("var a=[1,2,3]; a.push(4)"))
+ }
+
+ @Test
+ fun arrayPushMutates() {
+ assertEquals("1,2,3,4", str("var a=[1,2,3]; a.push(4); a.join(',')"))
+ }
+
+ @Test
+ fun arrayPopRemovesLastElement() {
+ assertEquals(3.0, num("var a=[1,2,3]; a.pop()"))
+ }
+
+ @Test
+ fun arrayPopMutates() {
+ assertEquals("1,2", str("var a=[1,2,3]; a.pop(); a.join(',')"))
+ }
+
+ @Test
+ fun arrayShift() {
+ assertEquals(1.0, num("var a=[1,2,3]; a.shift()"))
+ }
+
+ @Test
+ fun arrayShiftMutates() {
+ assertEquals("2,3", str("var a=[1,2,3]; a.shift(); a.join(',')"))
+ }
+
+ @Test
+ fun arrayUnshift() {
+ assertEquals(4.0, num("var a=[2,3,4]; a.unshift(1)"))
+ }
+
+ @Test
+ fun arrayUnshiftMutates() {
+ assertEquals("1,2,3,4", str("var a=[2,3,4]; a.unshift(1); a.join(',')"))
+ }
+
+ @Test
+ fun arraySlice() {
+ assertEquals("2,3", str("[1,2,3,4].slice(1,3).join(',')"))
+ }
+
+ @Test
+ fun arraySliceNegative() {
+ assertEquals("3,4", str("[1,2,3,4].slice(-2).join(',')"))
+ }
+
+ @Test
+ fun arraySpliceRemove() {
+ assertEquals("2,3", str("var a=[1,2,3,4]; a.splice(1,2).join(',')"))
+ }
+
+ @Test
+ fun arraySpliceMutates() {
+ assertEquals("1,4", str("var a=[1,2,3,4]; a.splice(1,2); a.join(',')"))
+ }
+
+ @Test
+ fun arraySpliceInsert() {
+ assertEquals("1,9,8,4", str("var a=[1,2,3,4]; a.splice(1,2,9,8); a.join(',')"))
+ }
+
+ @Test
+ fun arrayMap() {
+ assertEquals("2,4,6", str("[1,2,3].map(function(x){return x*2}).join(',')"))
+ }
+
+ @Test
+ fun arrayFilter() {
+ assertEquals("2,4", str("[1,2,3,4].filter(function(x){return x%2===0}).join(',')"))
+ }
+
+ @Test
+ fun arrayReduce() {
+ assertEquals(10.0, num("[1,2,3,4].reduce(function(acc,x){return acc+x},0)"))
+ }
+
+ @Test
+ fun arrayReduceNoInitial() {
+ assertEquals(10.0, num("[1,2,3,4].reduce(function(acc,x){return acc+x})"))
+ }
+
+ @Test
+ fun arrayForEachSideEffect() {
+ assertEquals(6.0, num("var s=0; [1,2,3].forEach(function(x){s+=x}); s"))
+ }
+
+ @Test
+ fun arrayFind() {
+ assertEquals(3.0, num("[1,2,3,4].find(function(x){return x>2})"))
+ }
+
+ @Test
+ fun arrayFindNotFound() {
+ assertEquals(Unit, evalJsInternal("[1,2,3].find(function(x){return x>9})"))
+ }
+
+ @Test
+ fun arrayIndexOf() {
+ assertEquals(2.0, num("[10,20,30].indexOf(30)"))
+ }
+
+ @Test
+ fun arrayIndexOfNotFound() {
+ assertEquals(-1.0, num("[10,20,30].indexOf(99)"))
+ }
+
+ @Test
+ fun arrayIncludes() {
+ assertTrue(bool("[1,2,3].includes(2)"))
+ }
+
+ @Test
+ fun arrayIncludesFalse() {
+ assertFalse(bool("[1,2,3].includes(9)"))
+ }
+
+ @Test
+ fun arrayConcat() {
+ assertEquals("1,2,3,4", str("[1,2].concat([3,4]).join(',')"))
+ }
+
+ @Test
+ fun arraySome() {
+ assertTrue(bool("[1,2,3].some(function(x){return x>2})"))
+ }
+
+ @Test
+ fun arraySomeFalse() {
+ assertFalse(bool("[1,2,3].some(function(x){return x>9})"))
+ }
+
+ @Test
+ fun arrayEvery() {
+ assertFalse(bool("[1,2,3].every(function(x){return x>2})"))
+ }
+
+ @Test
+ fun arrayEveryTrue() {
+ assertTrue(bool("[3,4,5].every(function(x){return x>2})"))
+ }
+
+ @Test
+ fun arraySortDefault() {
+ // Default sort is lexicographic: [10,9,2] => [10,2,9]
+ assertEquals("10,2,9", str("[10,9,2].sort().join(',')"))
+ }
+
+ @Test
+ fun arraySortWithComparator() {
+ assertEquals("1,2,10", str("[10,1,2].sort(function(a,b){return a-b}).join(',')"))
+ }
+
+ @Test
+ fun arrayFlat() {
+ assertEquals("1,2,3,4", str("[[1,2],[3,4]].flat().join(',')"))
+ }
+
+ @Test
+ fun arrayToString() {
+ assertEquals("1,2,3", str("[1,2,3].toString()"))
+ }
+
+ @Test
+ fun newArrayWithSize() {
+ assertEquals(5.0, num("new Array(5).length"))
+ }
+
+ @Test
+ fun objectPropertyAccessWithDot() {
+ assertEquals(1.0, num("var o={a:1}; o.a"))
+ }
+
+ @Test
+ fun objectPropertyAccessWithBracket() {
+ assertEquals(2.0, num("var o={b:2}; o['b']"))
+ }
+
+ @Test
+ fun objectPropertyAssignment() {
+ assertEquals(99.0, num("var o={}; o.x=99; o.x"))
+ }
+
+ @Test
+ fun objectKeys() {
+ assertEquals("a,b", str("Object.keys({a:1,b:2}).join(',')"))
+ }
+
+ @Test
+ fun objectValues() {
+ assertEquals("1,2", str("Object.values({a:1,b:2}).join(',')"))
+ }
+
+ @Test
+ fun objectToStringCoercion() {
+ // ({}) + "" => "[object Object]"
+ assertEquals("[object Object]", str("({})+''"))
+
+ // []+{} => "[object Object]"
+ assertEquals("[object Object]", str("[]+{}"))
+ }
+
+ @Test
+ fun ifTrueBranch() {
+ assertEquals(1.0, num("var r=0; if(true){r=1} r"))
+ }
+
+ @Test
+ fun ifFalseUsesElse() {
+ assertEquals(2.0, num("var r=0; if(false){r=1}else{r=2} r"))
+ }
+
+ @Test
+ fun ifElseIfChain() {
+ assertEquals(2.0, num("var x=5; var r=0; if(x<3){r=1}else if(x<7){r=2}else{r=3} r"))
+ }
+
+ @Test
+ fun whileLoop() {
+ assertEquals(10.0, num("var i=0; while(i<10){i++} i"))
+ }
+
+ @Test
+ fun whileBreak() {
+ assertEquals(5.0, num("var i=0; while(true){if(i===5)break; i++} i"))
+ }
+
+ @Test
+ fun whileContinue() {
+ assertEquals(25.0, num("var i=0; var s=0; while(i<10){i++; if(i%2===0)continue; s+=i} s"))
+ }
+
+ @Test
+ fun forLoop() {
+ assertEquals(10.0, num("var s=0; for(var i=1;i<=4;i++){s+=i} s"))
+ }
+
+ @Test
+ fun forLoopWithBreak() {
+ assertEquals(3.0, num("var i; for(i=0;i<10;i++){if(i===3)break} i"))
+ }
+
+ @Test
+ fun forLoopNoInitTestUpdate() {
+ // All three parts optional; behaves like while(true) with internal break
+ assertEquals(3.0, num("var i=0; for(;;){if(i>=3)break; i++} i"))
+ }
+
+ @Test
+ fun forInOverObjectKeys() {
+ assertEquals("a,b,c", str("var o={a:1,b:2,c:3}; var keys=[]; for(var k in o){keys.push(k)} keys.sort().join(',')"))
+ }
+
+ @Test
+ fun forInOverArrayGivesIndices() {
+ assertEquals("0,1,2", str("var a=[10,20,30]; var idx=[]; for(var i in a){idx.push(i)} idx.join(',')"))
+ }
+
+ @Test
+ fun namedFunctionDeclarationAndCall() {
+ assertEquals(7.0, num("function add(a,b){return a+b} add(3,4)"))
+ }
+
+ @Test
+ fun anonymousFunctionExpression() {
+ assertEquals(12.0, num("var mul=function(a,b){return a*b}; mul(3,4)"))
+ }
+
+ @Test
+ fun recursiveFunction() {
+ assertEquals(120.0, num("function fact(n){if(n<=1)return 1; return n*fact(n-1)} fact(5)"))
+ }
+
+ @Test
+ fun closureCapturesOuterVariable() {
+ assertEquals(3.0, num("var c=0; function inc(){c+=1} inc();inc();inc(); c"))
+ }
+
+ @Test
+ fun immediatelyInvokedFunctionExpression() {
+ assertEquals(9.0, num("(function(x){return x*x})(3)"))
+ }
+
+ @Test
+ fun functionAsArgument() {
+ assertEquals(6.0, num("function apply(f,x){return f(x)} apply(function(n){return n+1},5)"))
+ }
+
+ @Test
+ fun nestedClosure() {
+ val code = """
+ function makeAdder(n) {
+ return function(x) { return x + n; }
+ }
+ var add5 = makeAdder(5);
+ add5(3)
+ """.trimIndent()
+ assertEquals(8.0, num(code))
+ }
+
+ @Test
+ fun closureCounterFactory() {
+ val code = """
+ function makeCounter() {
+ var count = 0;
+ return function() { count += 1; return count; }
+ }
+ var c = makeCounter();
+ c(); c(); c()
+ """.trimIndent()
+ assertEquals(3.0, num(code))
+ }
+
+ @Test
+ fun argumentsObject() {
+ val code = """
+ function sum() {
+ var total = 0;
+ for(var i=0; i= 0.0 && r < 1.0, "Math.random() should be in [0,1) but was $r")
+ }
+
+ @Test
+ fun mathRandomProducesDifferentValues() {
+ val results = (1..20).map { num("Math.random()") }.toSet()
+ assertTrue(results.size > 1, "Math.random() produced identical values across 20 calls")
+ }
+
+ @Test
+ fun parseIntDecimal() {
+ assertEquals(42.0, num("parseInt('42')"))
+ }
+
+ @Test
+ fun parseIntHex() {
+ assertEquals(255.0, num("parseInt('ff',16)"))
+ }
+
+ @Test
+ fun parseIntBinary() {
+ assertEquals(5.0, num("parseInt('101',2)"))
+ }
+
+ @Test
+ fun parseIntInvalid() {
+ assertTrue(num("parseInt('abc')").isNaN())
+ }
+
+ @Test
+ fun parseIntLeadingWhitespace() {
+ assertEquals(42.0, num("parseInt(' 42 ')"))
+ }
+
+ @Test
+ fun parseFloat() {
+ assertApprox(3.14, num("parseFloat('3.14')"))
+ }
+
+ @Test
+ fun isNanTrue() {
+ assertTrue(bool("isNaN(NaN)"))
+ }
+
+ @Test
+ fun isNanFalse() {
+ assertFalse(bool("isNaN(1)"))
+ }
+
+ @Test
+ fun isFiniteFalse() {
+ assertFalse(bool("isFinite(Infinity)"))
+ }
+
+ @Test
+ fun isFiniteTrue() {
+ assertTrue(bool("isFinite(1)"))
+ }
+
+ @Test
+ fun consoleLogDoesNotThrow() {
+ // console.log is a no-op; just ensure it runs without exception
+ assertEquals(Unit, evalJsInternal("console.log('test', 1, 2)"))
+ }
+
+ @Test
+ fun decodeURIComponentBasic() {
+ assertEquals("hello world", str("decodeURIComponent('hello%20world')"))
+ }
+
+ @Test
+ fun encodeURIComponentBasic() {
+ assertTrue(str("encodeURIComponent('hello world')").contains("%"))
+ }
+
+ @Test
+ fun tryCatchSwallowsThrownValue() {
+ assertEquals(42.0, num("var r=0; try{throw 42}catch(e){r=e} r"))
+ }
+
+ @Test
+ fun finallyAlwaysRuns() {
+ assertEquals(99.0, num("var r=0; try{throw 1}catch(e){}finally{r=99} r"))
+ }
+
+ @Test
+ fun tryWithoutThrowSkipsCatch() {
+ assertEquals(1.0, num("var r=0; try{r=1}catch(e){r=99} r"))
+ }
+
+ @Test
+ fun tryCatchThrowString() {
+ assertEquals("oops", str("var r=''; try{throw 'oops'}catch(e){r=e} r"))
+ }
+
+ @Test
+ fun tryCatchThrowObject() {
+ assertEquals(404.0, num("var r=0; try{throw {code:404}}catch(e){r=e.code} r"))
+ }
+
+ @Test
+ fun evaluateMathSimpleAddition() {
+ assertEquals("5", jsValueToString(evalJsInternal("eval(2+3)")))
+ }
+
+ @Test
+ fun evaluateMathNestedParens() {
+ assertEquals("12", jsValueToString(evalJsInternal("eval((2+4)*2)")))
+ }
+
+ @Test
+ fun evaluateMathProducesCharCode() {
+ val code = "eval(1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1)"
+ assertEquals(65.0, (evalJsInternal(code) as? Double) ?: 0.0)
+ }
+
+ @Test
+ fun evalJsWithVariableReturnsNamedVar() {
+ assertEquals(42.0, num("var x = 42", "x"))
+ }
+
+ @Test
+ fun evalJsWithVariableAfterComputation() {
+ assertEquals(7.0, num("var x = 1 + 2 * 3", "x"))
+ }
+
+ @Test
+ fun evalJsWithVariableStringValue() {
+ assertEquals("https://example.com", str("var url = 'https://example.com'", "url"))
+ }
+
+ @Test
+ fun evalJsWithVariableNullValue() {
+ assertNull(evalJsInternal("var x = null", "x"))
+ }
+
+ @Test
+ fun evalJsWithVariableReturnsNullForUndefined() {
+ assertNull(evalJsInternal("var x = 42", "y"))
+ }
+
+ @Test
+ fun evalJsWithVariableUnitWhenNoVariable() {
+ assertEquals(Unit, evalJsInternal("var x = 42"))
+ }
+
+ @Test
+ fun evalJsWithVariableAfterMultipleStatements() {
+ assertEquals(15.0, num("var x = 0; for(var i=1;i<=5;i++){x+=i}", "x"))
+ }
+
+ @Test
+ fun emptyArrayPlusEmptyArrayIsEmptyString() {
+ // [] + [] => ""
+ assertEquals("", str("[]+[]"))
+ }
+
+ @Test
+ fun unaryPlusEmptyArrayIsZero() {
+ // +[] => 0
+ assertEquals(0.0, num("+[]"))
+ }
+
+ @Test
+ fun notArrayIsFalse() {
+ // ![] => false (array is truthy, so ![] is false)
+ assertFalse(bool("![]"))
+ }
+
+ @Test
+ fun doubleNotArrayIsTrue() {
+ // !![] => true
+ assertTrue(bool("!![]"))
+ }
+
+ @Test
+ fun unaryPlusDoubleNotArrayIsOne() {
+ // +!![] => 1
+ assertEquals(1.0, num("+!![]"))
+ }
+
+ @Test
+ fun unaryPlusNotArrayIsZero() {
+ // +![] => 0
+ assertEquals(0.0, num("+![]"))
+ }
+
+ @Test
+ fun falseCoercedToString() {
+ // ![]+[] => "false"
+ assertEquals("false", str("![]+[]"))
+ }
+
+ @Test
+ fun trueCoercedToString() {
+ // !![]+[] => "true"
+ assertEquals("true", str("!![]+[]"))
+ }
+
+ @Test
+ fun falseStringViaStringConcat() {
+ // (![]+""): false + "" => "false"
+ assertEquals("false", str("![]+''"))
+ }
+
+ @Test
+ fun trueStringViaStringConcat() {
+ // (!![]+""): true + "" => "true"
+ assertEquals("true", str("!![]+''"))
+ }
+
+ @Test
+ fun undefinedCoercedToString() {
+ assertEquals("undefined", str("[][0]+[]"))
+ }
+
+ @Test
+ fun charF() {
+ // (![]+[])[0] => "false"[0] => "f"
+ assertEquals("f", str("(![]+[])[0]"))
+ }
+
+ @Test
+ fun charA() {
+ // (![]+[])[1] => "false"[1] => "a"
+ assertEquals("a", str("(![]+[])[1]"))
+ }
+
+ @Test
+ fun charL() {
+ // (![]+[])[2] => "false"[2] => "l"
+ assertEquals("l", str("(![]+[])[2]"))
+ }
+
+ @Test
+ fun charS() {
+ // (![]+[])[3] => "false"[3] => "s"
+ assertEquals("s", str("(![]+[])[3]"))
+ }
+
+ @Test
+ fun charE() {
+ // (![]+[])[4] => "false"[4] => "e"
+ assertEquals("e", str("(![]+[])[4]"))
+ }
+
+ @Test
+ fun charT() {
+ // (!![]+[])[0] => "true"[0] => "t"
+ assertEquals("t", str("(!![]+[])[0]"))
+ }
+
+ @Test
+ fun charR() {
+ // (!![]+[])[1] => "true"[1] => "r"
+ assertEquals("r", str("(!![]+[])[1]"))
+ }
+
+ @Test
+ fun charU() {
+ // (!![]+[])[2] => "true"[2] => "u"
+ assertEquals("u", str("(!![]+[])[2]"))
+ }
+
+ @Test
+ fun indexViaArithmetic() {
+ // (![]+[])[+[]] => "false"[0] => "f" (index built from +[])
+ assertEquals("f", str("(![]+[])[+[]]"))
+ }
+
+ @Test
+ fun indexOneViaArithmetic() {
+ // (![]+[])[+!![]] => "false"[1] => "a"
+ assertEquals("a", str("(![]+[])[+!![]]"))
+ }
+
+ @Test
+ fun arrayToStringCoercion() {
+ // [1,2,3]+[] => "1,2,3"
+ assertEquals("1,2,3", str("[1,2,3]+[]"))
+ }
+
+ @Test
+ fun objectStringCharO() {
+ // ([]+{})[1] => "[object Object]"[1] => "o"
+ assertEquals("o", str("([]+{})[1]"))
+ }
+
+ @Test
+ fun objectStringCharB() {
+ // ([]+{})[2] => "[object Object]"[2] => "b"
+ assertEquals("b", str("([]+{})[2]"))
+ }
+
+ @Test
+ fun filterFunctionToString() {
+ // []["filter"]+"" => "function filter() { [native code] }"
+ assertEquals("function filter() { [native code] }", str("[]['filter']+''"))
+ }
+
+ @Test
+ fun filterStringCharF() {
+ // ([]["filter"]+[])[0] => "function filter() { [native code] }"[0] => "f"
+ assertEquals("f", str("([]['filter']+[])[0]"))
+ }
+
+ @Test
+ fun filterStringCharU() {
+ // ([]["filter"]+[])[1] => "u"
+ assertEquals("u", str("([]['filter']+[])[1]"))
+ }
+
+ @Test
+ fun filterStringCharN() {
+ // ([]["filter"]+[])[2] => "n"
+ assertEquals("n", str("([]['filter']+[])[2]"))
+ }
+
+ @Test
+ fun filterStringCharC() {
+ // ([]["filter"]+[])[3] => "c"
+ assertEquals("c", str("([]['filter']+[])[3]"))
+ }
+
+ @Test
+ fun filterStringCharI() {
+ assertEquals("i", str("([]['filter']+[])[5]"))
+ }
+
+ @Test
+ fun nativeCodeBracketChar() {
+ // "function filter() { [native code] }" contains '[' at index 20
+ val s = "function filter() { [native code] }"
+ val idx = s.indexOf('[')
+ assertEquals("[", str("([]['filter']+[])[$idx]"))
+ }
+
+ @Test
+ fun nativeCodeSpaceChar() {
+ // space at index 8
+ assertEquals(" ", str("([]['filter']+[])[8]"))
+ }
+
+ @Test
+ fun buildsNumberViaAddition() {
+ // +!![] + +!![] + +!![] => 3
+ assertEquals(3.0, num("+!![]+!![]+!![]"))
+ }
+
+ @Test
+ fun buildsNumberTen() {
+ assertEquals("10", str("(+!![])+[+[]]"))
+ }
+
+ @Test
+ fun stringFromCharCodeViaNativeExtraction() {
+ assertEquals("A", str("String['fromCharCode'](65)"))
+ }
+
+ @Test
+ fun fullAlphaFromFalseTrue() {
+ assertEquals("ftaseru", str("""
+ var f = ![]+[];
+ var t = !![]+[];
+ f[0]+t[0]+f[1]+f[3]+f[4]+t[1]+t[2]
+ """.trimIndent()))
+ }
+
+ @Test
+ fun functionToStringContainsNativeCode() {
+ // Any array method coerced to string should contain "native code"
+ assertTrue(str("[]['map']+''").contains("native code"))
+ }
+
+ @Test
+ fun functionToStringContainsFunctionKeyword() {
+ assertTrue(str("[]['join']+''").startsWith("function"))
+ }
+
+ @Test
+ fun typeofCoercion() {
+ // typeof([]) + [] => "object"
+ assertEquals("object", str("typeof([])+[]"))
+ }
+
+ @Test
+ fun typeofFunctionCoercion() {
+ // typeof([]['filter']) => "function"
+ assertEquals("function", str("typeof([]['filter'])"))
+ }
+
+ @Test
+ fun hexEncodedStringDecoding() {
+ val code = """
+ var encoded = '48|65|6c|6c|6f';
+ var decoded = encoded.split('|').map(function(h){
+ return String.fromCharCode(parseInt(h, 16));
+ }).join('');
+ decoded
+ """.trimIndent()
+ assertEquals("Hello", str(code))
+ }
+
+ @Test
+ fun charCodeArrayToString() {
+ val code = """
+ var codes = [72, 101, 108, 108, 111];
+ var s = '';
+ for(var i=0; i 0) {
+ r = alpha[n % 16] + r;
+ n = Math.floor(n / 16);
+ }
+ return r || '0';
+ }
+ toBase16(255)
+ """.trimIndent()
+ assertEquals("ff", str(code))
+ }
+
+ @Test
+ fun xorDeobfuscation() {
+ val code = """
+ var _0x1 = function(s) {
+ return s.split('').map(function(c) {
+ return String.fromCharCode(c.charCodeAt(0) ^ 1);
+ }).join('');
+ };
+ _0x1('ifmmp')
+ """.trimIndent()
+ val expected = "ifmmp".map { (it.code xor 1).toChar() }.joinToString("")
+ assertEquals(expected, str(code))
+ }
+
+ @Test
+ fun symtabLookupPattern() {
+ val code = """
+ var symtab = ['hello', '', 'world', 'foo'];
+ var tokens = '0 2'.split(' ');
+ var result = tokens.map(function(w){
+ var idx = parseInt(w,10);
+ var v = symtab[idx];
+ return (v !== undefined && v !== '') ? v : w;
+ }).join(' ');
+ result
+ """.trimIndent()
+ assertEquals("hello world", str(code))
+ }
+
+ @Test
+ fun hunterDecoderDufHelper() {
+ val code = """
+ function duf(d, e) {
+ var str = '0123456789abcdefghijklmnopqrstuvwxyz';
+ var h = str.substring(0, e);
+ var j = 0.0;
+ var rev = d.split('').reverse().join('');
+ for(var c=0; c= 0) j += idx * Math.pow(e, c);
+ }
+ return Math.floor(j);
+ }
+ duf('z', 36)
+ """.trimIndent()
+ assertEquals(35.0, num(code))
+ }
+
+ @Test
+ fun chainedStringMethods() {
+ assertEquals("OLLEH", str("'hello'.split('').reverse().join('').toUpperCase()"))
+ }
+
+ @Test
+ fun deeplyNestedArithmetic() {
+ assertEquals(39.0, num("((((1+1)*3)+((2*3)+1))*3)"))
+ }
+
+ @Test
+ fun closureOverLoopVariable() {
+ val code = """
+ var fns = [];
+ for(var i=0; i<3; i++){
+ (function(j){ fns.push(function(){return j;}); })(i);
+ }
+ fns[0]()+fns[1]()+fns[2]()
+ """.trimIndent()
+ assertEquals(3.0, num(code))
+ }
+
+ @Test
+ fun multilineStringConcatenation() {
+ val code = """
+ var a = 'foo';
+ var b = 'bar';
+ var c = a + b;
+ c
+ """.trimIndent()
+ assertEquals("foobar", str(code))
+ }
+
+ @Test
+ fun bitwiseTruncationPattern() {
+ assertEquals(5.0, num("(11/2)|0"))
+ }
+
+ @Test
+ fun infiniteLoopIsAbortedByExecutionBudget() {
+ assertEquals(Unit, evalJsInternal("while(true){}"))
+ }
+
+ @Test
+ fun infiniteLoopIsAbortedByTimeBudget() {
+ val mark = TimeSource.Monotonic.markNow()
+ val result = evalJsInternal("while(true){}", maxExecutionTime = 200.milliseconds)
+ assertEquals(Unit, result)
+ // Generous upper bound just to avoid flakiness on slow machines.
+ assertTrue(mark.elapsedNow() < 2.seconds)
+ }
+
+ @Test
+ fun infiniteLoopIsAbortedByTinyInstructionBudget() {
+ // A tiny instruction cap but a generous time budget, the instruction count is what
+ // should abort this, not the clock.
+ assertEquals(Unit, evalJsInternal("while(true){}", maxExecutionTime = 60.seconds, maxInstructions = 1000))
+ }
+
+ @Test
+ fun finiteLoopCompletesNormally() {
+ assertEquals(45.0, num("var s=0; for (var i=0;i<10;i++){ s+=i; }", "s"))
+ }
+
+ @Test
+ fun exponentiation() {
+ assertEquals(1024.0, num("2**10"))
+ }
+
+ @Test
+ fun exponentiationIsRightAssociative() {
+ // 2 ** (3 ** 2) == 2 ** 9 == 512, NOT (2 ** 3) ** 2 == 64
+ assertEquals(512.0, num("2**3**2"))
+ }
+
+ @Test
+ fun exponentiationWithNegativeExponent() {
+ assertEquals(0.5, num("2**-1"))
+ }
+
+ @Test
+ fun exponentiationOverflowsToInfinity() {
+ assertTrue(num("10**1000").isInfinite())
+ }
+
+ @Test
+ fun assignmentPowEquals() {
+ assertEquals(8.0, num("var x=2; x**=3", "x"))
+ }
+
+ @Test
+ fun legacyOctalLiteral() {
+ // 010 (octal) == 8
+ assertEquals(8.0, num("010"))
+ }
+
+ @Test
+ fun legacyOctalLiteralArithmetic() {
+ // 010 (octal, 8) - 03 (octal, 3) == 5
+ assertEquals(5.0, num("010 - 03"))
+ }
+
+ @Test
+ fun octalLikeLiteralWithNonOctalDigitIsDecimal() {
+ // "08" contains a non-octal digit (8), so JS treats it as plain decimal 8.
+ assertEquals(8.0, num("08"))
+ }
+
+ @Test
+ fun arrayElisionsCountTowardLength() {
+ assertEquals(3.0, num("[,,,].length"))
+ }
+
+ @Test
+ fun arrayTrailingCommaDoesNotAddElement() {
+ assertEquals(2.0, num("[1,2,].length"))
+ }
+
+ @Test
+ fun arrayTrailingElisionAfterCommaAddsAHole() {
+ assertEquals(3.0, num("[1,2,,].length"))
+ }
+
+ @Test
+ fun arrayHoleJoinsAsEmptyString() {
+ // Array.prototype.join treats holes/undefined/null as "", not the literal "undefined".
+ assertEquals("1,,3", str("[1,,3].join(',')"))
+ }
+
+ @Test
+ fun parseIntStopsAtExponentNotation() {
+ // 0.0000005 stringifies to "5e-7"; parseInt reads the leading "5" and stops at "e".
+ assertEquals(5.0, num("parseInt(0.0000005)"))
+ }
+
+ @Test
+ fun parseIntWithExplicitRadix() {
+ assertEquals(255.0, num("parseInt('ff', 16)"))
+ }
+
+ @Test
+ fun parseIntStopsAtFirstNonDigit() {
+ assertEquals(42.0, num("parseInt('42px')"))
+ }
+
+ @Test
+ fun parseIntInvalidInputIsNaN() {
+ assertTrue(num("parseInt('xyz')").isNaN())
+ }
+
+ @Test
+ fun emptyStringCoercesToZero() {
+ assertEquals(0.0, num("\"\" - 0"))
+ }
+
+ @Test
+ fun emptyStringMinusNegatedEmptyStringIsZero() {
+ assertEquals(0.0, evalJsInternal("\"\" - - \"\""))
+ }
+
+ @Test
+ fun emptyStringMinusNumber() {
+ assertEquals(-1.0, evalJsInternal("\"\" - 1"))
+ }
+
+ @Test
+ fun nullCoercesToZeroNotNaN() {
+ assertEquals(1.0, num("null + 1"))
+ }
+
+ @Test
+ fun undefinedCoercesToNaNNotZero() {
+ // Unlike null, undefined coerces to NaN, not 0.
+ assertTrue(num("undefined + 1").isNaN())
+ }
+
+ @Test
+ fun postfixIncrementOnNonLvalueFailsGracefully() {
+ assertEquals(2.0, evalJsInternal("true+1"))
+ // `true++` has no valid assignment target (a SyntaxError in real JS); evalJs falls
+ // back to Unit instead of returning a bogus number.
+ assertEquals(Unit, evalJsInternal("true++"))
+ }
+
+ @Test
+ fun booleanAdditionCoercesToNumber() {
+ assertEquals(1.0, num("true + false"))
+ }
+
+ @Test
+ fun arrayPlusArrayConcatenatesAsStrings() {
+ assertEquals("1,2,34,5,6", str("[1, 2, 3] + [4, 5, 6]"))
+ }
+
+ @Test
+ fun commaOperatorWithTwoOperands() {
+ assertEquals(2.0, num("10,2"))
+ }
+
+ @Test
+ fun doubleNegationOfEmptyStringIsFalse() {
+ assertFalse(bool("!!\"\""))
+ }
+
+ @Test
+ fun looseEqualityBooleanVsNonNumericStringIsFalse() {
+ assertFalse(bool("true == \"true\""))
+ }
+
+ @Test
+ fun nullPlusZeroIsZero() {
+ assertEquals(0.0, num("null + 0"))
+ }
+
+ @Test
+ fun zeroDividedByZeroIsNaN() {
+ assertTrue(num("0/0").isNaN())
+ }
+
+ @Test
+ fun exponentiationInfinityComparisonIsFalse() {
+ assertFalse(bool("1/0 > 10 ** 1000"))
+ }
+
+ @Test
+ fun nullMinusZeroThenStringConcat() {
+ assertEquals("00", str("(null - 0) + \"0\""))
+ }
+
+ @Test
+ fun nonNumericStringMinusNumberThenAddBoolean() {
+ assertTrue(num("true + (\"true\" - 0) ").isNaN())
+ }
+
+ @Test
+ fun negatedTruthyNumbersSumToZero() {
+ assertEquals(0.0, num("!5 + !5"))
+ }
+
+ @Test
+ fun numberAdditionThenStringConcatenation() {
+ assertEquals("33", str("1 + 2 + \"3\""))
+ }
+
+ @Test
+ fun typeofNaNIsNumber() {
+ assertEquals("number", str("typeof NaN"))
+ }
+
+ @Test
+ fun undefinedPlusFalseIsNaN() {
+ assertTrue(num("undefined + false").isNaN())
+ }
+
+ @Test
+ fun logicalAndShortCircuitsOnFalsyEmptyString() {
+ assertEquals("", str("\"\" && -0"))
+ }
+
+ @Test
+ fun combinedCoercionOfNaNEmptyStringAndArrayHoleIsZero() {
+ assertEquals(0.0, evalJsInternal("+!!NaN * \"\" - - [,]"))
+ }
+
+ /** Returns a [CoroutineScope] backed by a plain [Job] with no dispatcher attached. */
+ private fun activeScope(): CoroutineScope = CoroutineScope(Job())
+
+ @Test
+ fun scopeEvalJsFiniteScriptReturnsCorrectResult() {
+ // Normal script with an active scope should behave identically to plain evalJs.
+ val scope = activeScope()
+ val result = evalJsInternal("var s=0; for(var i=1;i<=10;i++){s+=i}", "s", scope = scope)
+ assertEquals(55.0, result as? Double ?: 0.0)
+ scope.cancel()
+ }
+
+ @Test
+ fun scopeEvalJsStringResultWithActiveScope() {
+ val scope = activeScope()
+ val result = jsValueToString(evalJsInternal("'hello'.split('').reverse().join('')", scope = scope))
+ assertEquals("olleh", result)
+ scope.cancel()
+ }
+
+ @Test
+ fun scopeEvalJsVariableLookupWithActiveScope() {
+ val scope = activeScope()
+ val result = evalJsInternal("var x = 21 * 2", "x", scope = scope)
+ assertEquals(42.0, result as? Double ?: 0.0)
+ scope.cancel()
+ }
+
+ @Test
+ fun scopeEvalJsCancelledBeforeCallThrowsJsCancellationException() {
+ // Cancel the scope before calling evalJs. The very first budget check sees
+ // isActive==false and throws JsCancellationException immediately.
+ val scope = activeScope()
+ scope.cancel()
+ assertFailsWith {
+ evalJsInternal("var x = 1 + 2", "x", scope = scope)
+ }
+ }
+
+ @Test
+ fun scopeEvalJsInfiniteLoopAbortedWhenScopeCancelled() {
+ // Pre-cancel the scope and confirm an infinite loop aborts well within the
+ // time budget. A sub-1s return against a 5s budget proves it was
+ // scope cancellation, not the clock, that stopped the script.
+ val scope = activeScope()
+ scope.cancel()
+ val mark = TimeSource.Monotonic.markNow()
+ assertFailsWith {
+ evalJsInternal("while(true){}", scope = scope)
+ }
+ assertTrue(
+ mark.elapsedNow() < 1.seconds,
+ "Expected abort well before 5s time budget; elapsed: ${mark.elapsedNow()}",
+ )
+ }
+
+ @Test
+ fun scopeEvalJsInfiniteLoopAbortedByOwnBudgetEvenWithActiveScope() {
+ // Even with an active (never-cancelled) scope the internal budget still fires.
+ val scope = activeScope()
+ val mark = TimeSource.Monotonic.markNow()
+ val result = evalJsInternal("while(true){}", maxExecutionTime = 200.milliseconds, scope = scope)
+ assertEquals(Unit, result)
+ assertTrue(mark.elapsedNow() < 2.seconds)
+ scope.cancel()
+ }
+
+ @Test
+ fun scopeEvalJsJsTryCatchCannotSwallowCancellation() {
+ // A JS try/catch must not be able to intercept the cancellation signal and keep
+ // the infinite loop alive. JsCancellationException extends CancellationException
+ // which the TryCatch node handler explicitly rethrows before catch(_: Exception).
+ val scope = activeScope()
+ scope.cancel()
+ val mark = TimeSource.Monotonic.markNow()
+ assertFailsWith {
+ evalJsInternal("while(true){ try{ throw 1; }catch(e){} }", scope = scope)
+ }
+ assertTrue(
+ mark.elapsedNow() < 1.seconds,
+ "JS try/catch appears to have swallowed the cancellation; elapsed: ${mark.elapsedNow()}",
+ )
+ }
+
+ @Test
+ fun scopeEvalJsCancelledScopeThrowsJsCancellationException() {
+ // A cancelled scope must propagate CancellationException so that withTimeout
+ // and structured concurrency see the cancellation correctly. Swallowing it
+ // silently as Unit would break withTimeout.
+ val scope = activeScope()
+ scope.cancel()
+ assertFailsWith {
+ evalJsInternal("1+1", scope = scope)
+ }
+ }
+
+ @Test
+ fun suspendEvalJsWithTimeoutCancelsInfiniteLoop() = runTest {
+ /**
+ * activeScope() provides a real CoroutineScope with a real Job, so withTimeout
+ * fires after a genuine 300ms rather than instantly via the virtual clock.
+ * The test coroutine suspends at done.receive(), keeping runTest alive until
+ * the background coroutine finishes.
+ *
+ * We measure elapsed time inside the coroutine. If withTimeout fired before
+ * evalJs started, elapsed would be ~0ms. ~300ms proves evalJs was genuinely
+ * running and cancelled mid-execution, not trivially before it began.
+ */
+ var elapsed = Duration.ZERO
+ val done = Channel()
+ activeScope().launch {
+ assertFailsWith {
+ withTimeout(300.milliseconds) {
+ val mark = TimeSource.Monotonic.markNow()
+ try {
+ evalJs("while(true){}")
+ } finally {
+ elapsed = mark.elapsedNow()
+ }
+ }
+ }
+
+ done.send(Unit)
+ }
+
+ done.receive()
+ assertTrue(elapsed > 200.milliseconds, "evalJs should have run for ~300ms but elapsed: $elapsed")
+ assertTrue(elapsed < 1.seconds, "evalJs ran too long: $elapsed")
+ }
+
+ @Test
+ fun newJsContextWithNoInitializerIsUsable() = runTest {
+ val ctx = newJsContext()
+ assertEquals(3.0, ctx.eval("1+2") as? Double ?: 0.0)
+ }
+
+ @Test
+ fun newJsContextInitializerRunsBeforeReturning() = runTest {
+ val ctx = newJsContext {
+ set("x", 10.0)
+ eval("var y = x + 1")
+ }
+
+ assertEquals(11.0, ctx["y"] as? Double ?: 0.0)
+ }
+
+ @Test
+ fun jsContextGetReturnsNullForNeverSetVariable() = runTest {
+ val ctx = newJsContext()
+ assertNull(ctx["neverSet"])
+ }
+
+ @Test
+ fun jsContextSetThenGetRoundTripsSameValue() = runTest {
+ val ctx = newJsContext()
+ ctx["greeting"] = "hello"
+ assertEquals("hello", ctx["greeting"])
+ }
+
+ @Test
+ fun jsContextSetOverwritesPreviousValue() = runTest {
+ val ctx = newJsContext()
+ ctx["x"] = 1.0
+ ctx["x"] = 2.0
+ assertEquals(2.0, ctx["x"] as? Double ?: 0.0)
+ }
+
+ @Test
+ fun jsContextSetNullIsRetrievedAsNull() = runTest {
+ val ctx = newJsContext()
+ ctx["x"] = null
+ assertNull(ctx["x"])
+ }
+
+ @Test
+ fun jsContextGetAfterEvalSetsVariable() = runTest {
+ val ctx = newJsContext()
+ ctx.eval("var fromJs = 42")
+ assertEquals(42.0, ctx["fromJs"] as? Double ?: 0.0)
+ }
+
+ @Test
+ fun jsContextPersistsVariablesAcrossManyEvalCalls() = runTest {
+ val ctx = newJsContext()
+ ctx.eval("var total = 0")
+ repeat(5) { ctx.eval("total += 1") }
+ assertEquals(5.0, ctx["total"] as? Double ?: 0.0)
+ }
+
+ @Test
+ fun jsContextFunctionDeclaredInOneEvalUsableInAnother() = runTest {
+ val ctx = newJsContext()
+ ctx.eval("function double(n) { return n * 2; }")
+ val result = ctx.eval("double(21)")
+ assertEquals(42.0, result as? Double ?: 0.0)
+ }
+
+ @Test
+ fun jsContextArrayMutatedAcrossEvalsPersists() = runTest {
+ val ctx = newJsContext()
+ ctx.eval("var arr = [1,2,3]")
+ ctx.eval("arr.push(4)")
+ assertEquals("1,2,3,4", ctx.eval("arr.join(',')") as? String)
+ }
+
+ @Test
+ fun jsContextEvalReturnsLastStatementValue() = runTest {
+ val ctx = newJsContext()
+ assertEquals(9.0, ctx.eval("var a = 3; a * a") as? Double ?: 0.0)
+ }
+
+ @Test
+ fun jsContextEvalReturnsUnitForDeclarationOnlyStatement() = runTest {
+ val ctx = newJsContext()
+ assertEquals(Unit, ctx.eval("var a = 3"))
+ }
+
+ @Test
+ fun jsContextEvalReturnsStringValue() = runTest {
+ val ctx = newJsContext()
+ assertEquals("ab", ctx.eval("'a' + 'b'"))
+ }
+
+ @Test
+ fun separateJsContextsDoNotShareVariables() = runTest {
+ val ctx1 = newJsContext { set("x", 1.0) }
+ val ctx2 = newJsContext { set("x", 2.0) }
+ assertEquals(1.0, ctx1["x"] as? Double ?: 0.0)
+ assertEquals(2.0, ctx2["x"] as? Double ?: 0.0)
+ }
+
+ @Test
+ fun separateJsContextsDoNotShareFunctions() = runTest {
+ val ctx1 = newJsContext { eval("function f(){ return 1; }") }
+ val ctx2 = newJsContext()
+ assertEquals(1.0, ctx1.eval("f()") as? Double ?: 0.0)
+ // f was never declared in ctx2, so calling it should not crash and yields Unit.
+ assertEquals(Unit, ctx2.eval("typeof f === 'function' ? f() : undefined"))
+ }
+
+ @Test
+ fun jsContextEvalDefaultBudgetHandlesNormalScript() = runTest {
+ val ctx = newJsContext()
+ val result = ctx.eval("var s=0; for(var i=1;i<=1000;i++){s+=i} s")
+ assertEquals(500500.0, result as? Double ?: 0.0)
+ }
+
+ @Test
+ fun jsContextEvalInfiniteLoopAbortedByDefaultInstructionBudget() = runTest {
+ val ctx = newJsContext()
+ assertEquals(Unit, ctx.eval("while(true){}"))
+ }
+
+ @Test
+ fun jsContextEvalInfiniteLoopAbortedByCustomTimeBudget() = runTest {
+ val ctx = newJsContext()
+ ctx.maxExecutionTime = 200.milliseconds
+ val mark = TimeSource.Monotonic.markNow()
+ val result = ctx.eval("while(true){}")
+ assertEquals(Unit, result)
+ assertTrue(mark.elapsedNow() < 2.seconds)
+ }
+
+ @Test
+ fun jsContextEvalInfiniteLoopAbortedByTinyInstructionBudget() = runTest {
+ val ctx = newJsContext()
+ ctx.maxExecutionTime = 60.seconds
+ ctx.maxInstructions = 1000
+ // High time budget; instruction cap should be what stops it.
+ val result = ctx.eval("while(true){}")
+ assertEquals(Unit, result)
+ }
+
+ @Test
+ fun jsContextEvalCustomBudgetAppliesOnlyToThatCall() = runTest {
+ val ctx = newJsContext()
+ ctx.maxInstructions = 100
+ // Abort this call
+ assertEquals(Unit, ctx.eval("while(true){}"))
+ // The next call, using the defaults again, runs a normal script fine.
+ val result = ctx.eval("1+1")
+ assertEquals(2.0, result as? Double ?: 0.0)
+ }
+
+ @Test
+ fun jsContextEvalVariablesSurviveAnAbortedPriorCall() = runTest {
+ val ctx = newJsContext()
+ ctx.maxInstructions = 100
+ ctx.eval("var x = 5")
+ ctx.eval("while(true){}")
+ assertEquals(5.0, ctx["x"] as? Double ?: 0.0)
+ }
+
+ @Test
+ fun jsContextEvalCancelledWhenEnclosingCoroutineCancelledViaWithTimeout() = runTest {
+ val done = Channel()
+ var elapsed = Duration.ZERO
+ activeScope().launch {
+ assertFailsWith {
+ withTimeout(300.milliseconds) {
+ val ctx = newJsContext()
+ val mark = TimeSource.Monotonic.markNow()
+ try {
+ ctx.eval("while(true){}")
+ } finally {
+ elapsed = mark.elapsedNow()
+ }
+ }
+ }
+
+ done.send(Unit)
+ }
+
+ done.receive()
+ assertTrue(elapsed > 200.milliseconds, "expected genuine ~300ms run, was $elapsed")
+ assertTrue(elapsed < 1.seconds, "expected prompt cancellation, was $elapsed")
+ }
+
+ @Test
+ fun jsContextEvalNotCancelledWhenWithTimeoutDoesNotExpire() = runTest {
+ val ctx = newJsContext()
+ val result = withTimeoutOrNull(5.seconds) {
+ ctx.eval("var s=0; for(var i=0;i<100;i++){s+=i} s")
+ }
+
+ assertEquals(4950.0, result as? Double ?: 0.0)
+ }
+
+ @Test
+ fun jsContextJsTryCatchCannotSwallowEnclosingCancellation() = runTest {
+ val done = Channel()
+ var elapsed = Duration.ZERO
+ activeScope().launch {
+ assertFailsWith {
+ withTimeout(300.milliseconds) {
+ val ctx = newJsContext()
+ val mark = TimeSource.Monotonic.markNow()
+ try {
+ ctx.eval("while(true){ try{ throw 1; }catch(e){} }")
+ } finally {
+ elapsed = mark.elapsedNow()
+ }
+ }
+ }
+ done.send(Unit)
+ }
+
+ done.receive()
+ assertTrue(elapsed < 1.seconds, "JS try/catch appears to have swallowed cancellation; elapsed: $elapsed")
+ }
+
+ @Test
+ fun jsContextUrlExtractionPattern() = runTest {
+ val ctx = newJsContext()
+ ctx.eval("var url = '/e/abc123?t=' + (1000+337) + '&s=xyz'")
+ assertEquals("/e/abc123?t=1337&s=xyz", ctx["url"]?.toString())
+ }
+
+ @Test
+ fun jsContextInitializerCanReadBackItsOwnEvalResult() = runTest {
+ var capturedDuringInit: Any? = null
+ newJsContext {
+ capturedDuringInit = eval("2 * 21")
+ }
+
+ assertEquals(42.0, capturedDuringInit as? Double ?: 0.0)
+ }
+
+ @Test
+ fun jsContextSequentialIndependentComputations() = runTest {
+ val ctx = newJsContext()
+ assertEquals(4.0, ctx.eval("2+2") as? Double ?: 0.0)
+ assertEquals("hi", ctx.eval("'h'+'i'") as? String)
+ assertFalse(ctx.eval("1 > 2") as? Boolean ?: true)
+ }
+}
From 59eaed0c82515d3adfd843cbc6f62f0760b539e0 Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Thu, 9 Jul 2026 11:08:53 +0000
Subject: [PATCH 34/94] Fix: Subsouce and cache issue (#3033)
---
.../syncproviders/AccountManager.kt | 6 +-
.../syncproviders/SubtitleRepo.kt | 8 +-
.../syncproviders/providers/SubSource.kt | 223 ++++++++++--------
3 files changed, 133 insertions(+), 104 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt
index 3bc5f2733..68fba9777 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt
@@ -70,7 +70,8 @@ abstract class AccountManager {
SubtitleRepo(openSubtitlesApi),
SubtitleRepo(addic7ed),
SubtitleRepo(subDlApi),
- PlainAuthRepo(animeSkipApi)
+ PlainAuthRepo(animeSkipApi),
+ SubtitleRepo(subSourceApi)
)
fun updateAccountIds() {
@@ -120,7 +121,8 @@ abstract class AccountManager {
val subtitleProviders = arrayOf(
SubtitleRepo(openSubtitlesApi),
SubtitleRepo(addic7ed),
- SubtitleRepo(subDlApi)
+ SubtitleRepo(subDlApi),
+ SubtitleRepo(subSourceApi)
)
val syncApis = arrayOf(
SyncRepo(malApi),
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt
index 0b8c3e5ae..161001611 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt
@@ -2,7 +2,6 @@ package com.lagradost.cloudstream3.syncproviders
import androidx.annotation.WorkerThread
import com.lagradost.cloudstream3.APIHolder.unixTime
-import com.lagradost.cloudstream3.ErrorLoadingException
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity
import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch
import com.lagradost.cloudstream3.subtitles.SubtitleResource
@@ -14,7 +13,8 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
data class SavedSearchResponse(
val unixTime: Long,
val response: List,
- val query: SubtitleSearch
+ val query: SubtitleSearch,
+ val idPrefix: String,
)
data class SavedResourceResponse(
@@ -66,7 +66,7 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
var found: List? = null
for (item in searchCache) {
// 120 min save
- if (item.query == query && (unixTime - item.unixTime) < 60 * 120) {
+ if (item.idPrefix == idPrefix && item.query == query && (unixTime - item.unixTime) < 60 * 120) {
found = item.response
break
}
@@ -79,7 +79,7 @@ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) {
// only cache valid return values
if (returnValue.isNotEmpty()) {
- val add = SavedSearchResponse(unixTime, returnValue, query)
+ val add = SavedSearchResponse(unixTime, returnValue, query, idPrefix)
searchCache.withLock {
if (searchCache.size > CACHE_SIZE) {
searchCache[searchCacheIndex] = add // rolling cache
diff --git a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt
index fb463ebee..317c3dc90 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt
@@ -7,11 +7,10 @@ import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities
import com.lagradost.cloudstream3.subtitles.SubtitleResource
import com.lagradost.cloudstream3.syncproviders.AuthData
import com.lagradost.cloudstream3.syncproviders.SubtitleAPI
-import com.lagradost.cloudstream3.utils.AppUtils.parseJson
-import com.lagradost.cloudstream3.utils.AppUtils.toJson
import com.lagradost.cloudstream3.utils.SubtitleHelper
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
+import java.util.concurrent.TimeUnit
class SubSourceApi : SubtitleAPI() {
override val name = "SubSource"
@@ -20,77 +19,70 @@ class SubSourceApi : SubtitleAPI() {
override val requiresLogin = false
companion object {
- const val APIURL = "https://api.subsource.net/api"
- const val DOWNLOADENDPOINT = "https://api.subsource.net/api/downloadSub"
+ const val APIURL = "https://api.subsource.net/v1"
}
override suspend fun search(
auth: AuthData?,
query: AbstractSubtitleEntities.SubtitleSearch
): List? {
-
//Only supports Imdb Id search for now
if (query.imdbId == null) return null
val queryLang = SubtitleHelper.fromTagToEnglishLanguageName(query.lang)
val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie
- val searchRes = app.post(
- url = "$APIURL/searchMovie",
- data = mapOf(
- "query" to query.imdbId!!
- )
- ).parsedSafe() ?: return null
+ val searchResponse = app.post(
+ url = "$APIURL/movie/search",
+ json = mapOf(
+ "includeSeasons" to false,
+ "limit" to 15,
+ "query" to query.imdbId!!,
+ "signal" to "{}"
+ ),
+ cacheTime = 120,
+ cacheUnit = TimeUnit.MINUTES,
+ ).parsedSafe() ?: return null
- val postData = if (type == TvType.TvSeries) {
- mapOf(
- "langs" to "[]",
- "movieName" to searchRes.found.first().linkName,
- "season" to "season-${query.seasonNumber}"
- )
- } else {
- mapOf(
- "langs" to "[]",
- "movieName" to searchRes.found.first().linkName,
- )
+ val firstResult = searchResponse.results.firstOrNull() ?: return null
+
+ val apiResponse = app.get(
+ url = "$APIURL${firstResult.link.replace("series", "subtitles")}",
+ cacheTime = 120,
+ cacheUnit = TimeUnit.MINUTES,
+ ).parsedSafe() ?: return null
+
+ val filteredSubtitles = apiResponse.subtitles.filter { sub ->
+ sub.releaseType != "trailer" &&
+ sub.language.equals(queryLang, true)
}
- val getMovieRes = app.post(
- url = "$APIURL/getMovie",
- data = postData
- ).parsedSafe().let {
- // api doesn't has episode number or lang filtering
- if (type == TvType.Movie) {
- it?.subs?.filter { sub ->
- sub.lang == queryLang
- }
- } else {
- it?.subs?.filter { sub ->
- sub.releaseName!!.contains(
- String.format(
- null,
- "E%02d",
- query.epNumber
- )
- ) && sub.lang == queryLang
- }
+ // api doesn't has episode number or lang filtering
+ val subtitles = if (type == TvType.Movie) {
+ filteredSubtitles
+ } else {
+ val shouldContain = String.format(
+ null,
+ "E%02d",
+ query.epNumber
+ )
+ filteredSubtitles.filter { sub ->
+ sub.releaseInfo.contains(
+ shouldContain
+ )
}
- } ?: return null
+ }
- return getMovieRes.map { subtitle ->
+ return subtitles.map { subtitle ->
AbstractSubtitleEntities.SubtitleEntity(
idPrefix = this.idPrefix,
- name = subtitle.releaseName!!,
- lang = subtitle.lang!!,
- data = SubData(
- movie = subtitle.linkName!!,
- lang = subtitle.lang,
- id = subtitle.subId.toString(),
- ).toJson(),
+ name = subtitle.releaseInfo,
+ lang = subtitle.language,
+ data = subtitle.link,
type = type,
source = this.name,
epNumber = query.epNumber,
seasonNumber = query.seasonNumber,
- isHearingImpaired = subtitle.hi == 1,
+ isHearingImpaired = subtitle.hearingImpaired == 1,
)
}
}
@@ -99,79 +91,114 @@ class SubSourceApi : SubtitleAPI() {
auth: AuthData?,
subtitle: AbstractSubtitleEntities.SubtitleEntity
) {
- val parsedSub = parseJson(subtitle.data)
-
- val subRes = app.post(
- url = "$APIURL/getSub",
- data = mapOf(
- "movie" to parsedSub.movie,
- "lang" to subtitle.lang,
- "id" to parsedSub.id
- )
- ).parsedSafe() ?: return
+ val data = app.get("$APIURL/subtitle/${subtitle.data}")
+ .parsedSafe()
+ ?: return
this.addZipUrl(
- "$DOWNLOADENDPOINT/${subRes.sub.downloadToken}"
+ "$APIURL/subtitle/download/${data.subtitle.downloadToken}"
) { name, _ ->
name
}
}
+
@Serializable
- data class ApiSearch(
- @JsonProperty("success") @SerialName("success") val success: Boolean,
- @JsonProperty("found") @SerialName("found") val found: List,
+ data class SearchRoot(
+ @JsonProperty("success") @SerialName("success") var success: Boolean? = null,
+ @JsonProperty("results") @SerialName("results") var results: ArrayList = arrayListOf(),
+ @JsonProperty("users") @SerialName("users") var users: ArrayList = arrayListOf()
)
@Serializable
- data class Found(
- @JsonProperty("id") @SerialName("id") val id: Long,
- @JsonProperty("title") @SerialName("title") val title: String,
- @JsonProperty("seasons") @SerialName("seasons") val seasons: Long,
- @JsonProperty("type") @SerialName("type") val type: String,
- @JsonProperty("releaseYear") @SerialName("releaseYear") val releaseYear: Long,
- @JsonProperty("linkName") @SerialName("linkName") val linkName: String,
+ data class Users(
+
+ @JsonProperty("id") @SerialName("id") var id: Int? = null,
+ @JsonProperty("displayname") @SerialName("displayname") var displayname: String? = null,
+ @JsonProperty("avatar") @SerialName("avatar") var avatar: String? = null,
+ @JsonProperty("badges") @SerialName("badges") var badges: ArrayList = arrayListOf()
+
)
@Serializable
- data class ApiResponse(
- @JsonProperty("success") @SerialName("success") val success: Boolean,
- @JsonProperty("movie") @SerialName("movie") val movie: Movie,
- @JsonProperty("subs") @SerialName("subs") val subs: List,
+ data class Results(
+ @JsonProperty("id") @SerialName("id") var id: Int? = null,
+ @JsonProperty("title") @SerialName("title") var title: String? = null,
+ @JsonProperty("type") @SerialName("type") var type: String? = null,
+ @JsonProperty("link") @SerialName("link") var link: String,
+ @JsonProperty("releaseYear") @SerialName("releaseYear") var releaseYear: Int? = null,
+ @JsonProperty("poster") @SerialName("poster") var poster: String? = null,
+ @JsonProperty("subtitleCount") @SerialName("subtitleCount") var subtitleCount: String? = null,
+ @JsonProperty("rating") @SerialName("rating") var rating: Double? = null,
+ @JsonProperty("cast") @SerialName("cast") var cast: ArrayList = arrayListOf(),
+ @JsonProperty("genres") @SerialName("genres") var genres: ArrayList = arrayListOf(),
+ @JsonProperty("score") @SerialName("score") var score: Double? = null
)
@Serializable
- data class Movie(
- @JsonProperty("id") @SerialName("id") val id: Long? = null,
- @JsonProperty("type") @SerialName("type") val type: String? = null,
- @JsonProperty("year") @SerialName("year") val year: Long? = null,
- @JsonProperty("fullName") @SerialName("fullName") val fullName: String? = null,
+
+ data class ItemRoot(
+
+ // @SerialName("media_type" ) var mediaType : String? = null,
+ @JsonProperty("subtitles") @SerialName("subtitles") var subtitles: ArrayList,
+ //@SerialName("movie" ) var movie : Movie? = Movie()
+
)
@Serializable
- data class Sub(
- @JsonProperty("hi") @SerialName("hi") val hi: Int? = null,
- @JsonProperty("fullLink") @SerialName("fullLink") val fullLink: String? = null,
- @JsonProperty("linkName") @SerialName("linkName") val linkName: String? = null,
- @JsonProperty("lang") @SerialName("lang") val lang: String? = null,
- @JsonProperty("releaseName") @SerialName("releaseName") val releaseName: String? = null,
- @JsonProperty("subId") @SerialName("subId") val subId: Long? = null,
+ data class Subtitles(
+
+ @JsonProperty("id") @SerialName("id") var id: Int? = null,
+ @JsonProperty("language") @SerialName("language") var language: String,
+ @JsonProperty("release_type") @SerialName("release_type") var releaseType: String? = null,
+ @JsonProperty("release_info") @SerialName("release_info") var releaseInfo: String,
+ @JsonProperty("upload_date") @SerialName("upload_date") var uploadDate: String? = null,
+ @JsonProperty("hearing_impaired") @SerialName("hearing_impaired") var hearingImpaired: Int? = null,
+ @JsonProperty("caption") @SerialName("caption") var caption: String? = null,
+ @JsonProperty("rating") @SerialName("rating") var rating: String? = null,
+ @JsonProperty("uploader_id") @SerialName("uploader_id") var uploaderId: Int? = null,
+ @JsonProperty("uploader_displayname") @SerialName("uploader_displayname") var uploaderDisplayname: String? = null,
+ @JsonProperty("uploader_badges") @SerialName("uploader_badges") var uploaderBadges: ArrayList = arrayListOf(),
+ @JsonProperty("link") @SerialName("link") var link: String,
+ @JsonProperty("production_type") @SerialName("production_type") var productionType: String? = null,
+ @JsonProperty("last_subtitle") @SerialName("last_subtitle") var lastSubtitle: Boolean? = null
+
)
@Serializable
- data class SubData(
- @JsonProperty("movie") @SerialName("movie") val movie: String,
- @JsonProperty("lang") @SerialName("lang") val lang: String,
- @JsonProperty("id") @SerialName("id") val id: String,
+ data class DownloadRoot(
+ @JsonProperty("subtitle") @SerialName("subtitle") var subtitle: Subtitle,
+ //@SerializedName("movie" ) var movie : Movie? = Movie(),
+ //@SerializedName("donationLinks" ) var donationLinks : DonationLinks? = DonationLinks(),
+ //@SerializedName("isDownloaded" ) var isDownloaded : Boolean? = null,
+ //@SerializedName("user_rated" ) var userRated : String? = null
)
@Serializable
- data class SubTitleLink(
- @JsonProperty("sub") @SerialName("sub") val sub: SubToken,
- )
+ data class Subtitle(
+
+ @JsonProperty("id") @SerialName("id") var id: Int? = null,
+ @JsonProperty("uploaded_at") @SerialName("uploaded_at") var uploadedAt: String? = null,
+ @JsonProperty("language") @SerialName("language") var language: String? = null,
+ @JsonProperty("rating") @SerialName("rating") var rating: String? = null,
+ //SerialName("rates" ) var rates : Rates? = Rates(),
+ @JsonProperty("uploaded_by") @SerialName("uploaded_by") var uploadedBy: Int? = null,
+ //@SerialName("contribs" ) var contribs : ArrayList = arrayListOf(),
+ @JsonProperty("release_info") @SerialName("release_info") var releaseInfo: ArrayList = arrayListOf(),
+ @JsonProperty("commentary") @SerialName("commentary") var commentary: String? = null,
+ @JsonProperty("files") @SerialName("files") var files: String? = null,
+ @JsonProperty("size") @SerialName("size") var size: String? = null,
+ @JsonProperty("downloads") @SerialName("downloads") var downloads: Int? = null,
+ @JsonProperty("comments") @SerialName("comments") var comments: Int? = null,
+ @JsonProperty("production_type") @SerialName("production_type") var productionType: String? = null,
+ @JsonProperty("release_type") @SerialName("release_type") var releaseType: String? = null,
+ @JsonProperty("episode") @SerialName("episode") var episode: String? = null,
+ @JsonProperty("hearing_impaired") @SerialName("hearing_impaired") var hearingImpaired: Int? = null,
+ @JsonProperty("foreign_parts") @SerialName("foreign_parts") var foreignParts: String? = null,
+ @JsonProperty("framerate") @SerialName("framerate") var framerate: String? = null,
+ @JsonProperty("preview") @SerialName("preview") var preview: String? = null,
+ @JsonProperty("user_uploaded") @SerialName("user_uploaded") var userUploaded: Boolean? = null,
+ @JsonProperty("download_token") @SerialName("download_token") var downloadToken: String
- @Serializable
- data class SubToken(
- @JsonProperty("downloadToken") @SerialName("downloadToken") val downloadToken: String,
)
}
From e428d422ebdd8d4def84c555401fbd55b7e39a73 Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Thu, 9 Jul 2026 11:12:37 +0000
Subject: [PATCH 35/94] Fix: Remove uiReset for better UX for errors (#3023)
---
.../com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
index b64355e23..91e3ff970 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt
@@ -509,7 +509,8 @@ class GeneratorPlayer : FullScreenPlayer() {
showDownloadProgress(DownloadEvent(0, 0, 0, null))
- uiReset()
+ // uiReset() // Removed due to UX
+
currentSelectedLink = link
// setEpisodes(viewModel.getAllMeta() ?: emptyList())
setPlayerDimen(null)
From 66afc3b1eec4a387113312770b4765d175179ba9 Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Thu, 9 Jul 2026 15:50:05 +0000
Subject: [PATCH 36/94] Feat: Animation to actor adapter (#3035)
---
.../cloudstream3/ui/result/ActorAdaptor.kt | 23 ++++++++++++++++++-
app/src/main/res/layout/cast_item.xml | 1 +
2 files changed, 23 insertions(+), 1 deletion(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt
index 056588d0b..fe8c617f2 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt
@@ -5,6 +5,9 @@ import android.content.Intent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.view.animation.Animation
+import android.view.animation.OvershootInterpolator
+import android.view.animation.ScaleAnimation
import androidx.core.view.isVisible
import com.lagradost.cloudstream3.ActorData
import com.lagradost.cloudstream3.ActorRole
@@ -46,6 +49,24 @@ class ActorAdaptor(
}
}
+ override fun onUpdateContent(holder: ViewHolderState, item: ActorData, position: Int) {
+ when (val binding = holder.view) {
+ is CastItemBinding -> {
+ val anim: Animation = ScaleAnimation(
+ 0.8f, 1f,
+ 0.8f, 1f,
+ Animation.RELATIVE_TO_SELF, 0.5f,
+ Animation.RELATIVE_TO_SELF, 0.5f
+ )
+ anim.fillAfter = true
+ anim.duration = 200
+ anim.interpolator = OvershootInterpolator()
+ binding.voiceActorImageHolder2.startAnimation(anim)
+ }
+ }
+ super.onUpdateContent(holder, item, position)
+ }
+
override fun onBindContent(holder: ViewHolderState, item: ActorData, position: Int) {
when (val binding = holder.view) {
is CastItemBinding -> {
@@ -137,4 +158,4 @@ class ActorAdaptor(
}
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/res/layout/cast_item.xml b/app/src/main/res/layout/cast_item.xml
index 4f7bdf74d..610b4e3e4 100644
--- a/app/src/main/res/layout/cast_item.xml
+++ b/app/src/main/res/layout/cast_item.xml
@@ -47,6 +47,7 @@
Date: Thu, 9 Jul 2026 15:51:16 +0000
Subject: [PATCH 37/94] Remove AM/PM when on 24h settings
---
.../lagradost/cloudstream3/ui/settings/SettingsFragment.kt | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt
index e41109b59..6943e5cf4 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt
@@ -249,7 +249,7 @@ class SettingsFragment : BaseFragment(
val appVersion = BuildConfig.VERSION_NAME
val commitHash = activity?.currentCommitHash() ?: ""
- val buildTimestamp = SimpleDateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG,
+ val buildTimestamp = SimpleDateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.MEDIUM,
Locale.getDefault()
).apply { timeZone = TimeZone.getTimeZone("UTC")
}.format(Date(BuildConfig.BUILD_DATE)).replace("UTC", "")
@@ -262,4 +262,4 @@ class SettingsFragment : BaseFragment(
true
}
}
-}
\ No newline at end of file
+}
From 11202d269de03ac38bc3fd09efc441d1f2011966 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Thu, 9 Jul 2026 11:40:00 -0600
Subject: [PATCH 38/94] [skip ci] SyncUtil: fix type for tryParseJson (#3038)
---
app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt
index b8e2c1b76..43e61ad88 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt
@@ -72,7 +72,7 @@ object SyncUtil {
// Gogoanime, Twistmoe and 9anime
val url = "https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/pages/$site/$slug.json"
val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).text
- val mapped = tryParseJson(response)
+ val mapped = tryParseJson(response)
val overrideMal = mapped?.malId ?: mapped?.mal?.id ?: mapped?.anilist?.malId
val overrideAnilist = mapped?.aniId ?: mapped?.anilist?.id
From 0e3191e28b843e5b0f838e39fea056d69170fad1 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Thu, 9 Jul 2026 11:41:14 -0600
Subject: [PATCH 39/94] [skip ci] Update commented deprecation message for
getRhinoContext (#3037)
---
.../kotlin/com/lagradost/cloudstream3/MainAPI.kt | 9 +--------
1 file changed, 1 insertion(+), 8 deletions(-)
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
index 72d69dc45..99421a800 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
@@ -797,16 +797,9 @@ fun fixTitle(str: String): String {
}
}
-/**
- * Get rhino context in a safe way as it needs to be initialized on the main thread.
- *
- * Make sure you get the scope using: val scope: Scriptable = rhino.initSafeStandardObjects()
- *
- * Use like the following: rhino.evaluateString(scope, js, "JavaScript", 1, null)
- **/
// Deprecate after next stable
/* @Deprecated(
- message = "Use JsContext or evalJs instead.",
+ message = "Use newJsContext or evalJs instead.",
level = DeprecationLevel.WARNING,
) */
suspend fun getRhinoContext(): org.mozilla.javascript.Context {
From 8bdc5999968265d74dae8ddff3656950be255edc Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Thu, 9 Jul 2026 11:43:49 -0600
Subject: [PATCH 40/94] test: some cleanup to JsInterpreterTest (#3036)
---
.../cloudstream3/utils/JsInterpreterTest.kt | 55 +++++++++----------
1 file changed, 27 insertions(+), 28 deletions(-)
diff --git a/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/JsInterpreterTest.kt b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/JsInterpreterTest.kt
index 182ea6c95..ded236f4e 100644
--- a/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/JsInterpreterTest.kt
+++ b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/JsInterpreterTest.kt
@@ -28,6 +28,10 @@ class JsInterpreterTest {
private fun num(code: String, variable: String? = null): Double = evalJsInternal(code, variable) as? Double ?: Double.NaN
private fun str(code: String, variable: String? = null): String = jsValueToString(evalJsInternal(code, variable))
+ private suspend fun JsContext.bool(code: String): Boolean = eval(code) as? Boolean ?: false
+ private suspend fun JsContext.num(code: String): Double = eval(code) as? Double ?: Double.NaN
+ private suspend fun JsContext.str(code: String): String = jsValueToString(eval(code))
+
private fun assertApprox(expected: Double, actual: Double, tol: Double = 1e-9) {
assertTrue(abs(actual - expected) <= tol, "Expected $expected ± $tol but was $actual")
}
@@ -1384,18 +1388,18 @@ class JsInterpreterTest {
@Test
fun evaluateMathSimpleAddition() {
- assertEquals("5", jsValueToString(evalJsInternal("eval(2+3)")))
+ assertEquals("5", str("eval(2+3)"))
}
@Test
fun evaluateMathNestedParens() {
- assertEquals("12", jsValueToString(evalJsInternal("eval((2+4)*2)")))
+ assertEquals("12", str("eval((2+4)*2)"))
}
@Test
fun evaluateMathProducesCharCode() {
val code = "eval(1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1+1)"
- assertEquals(65.0, (evalJsInternal(code) as? Double) ?: 0.0)
+ assertEquals(65.0, num(code))
}
@Test
@@ -1925,12 +1929,12 @@ class JsInterpreterTest {
@Test
fun emptyStringMinusNegatedEmptyStringIsZero() {
- assertEquals(0.0, evalJsInternal("\"\" - - \"\""))
+ assertEquals(0.0, num("\"\" - - \"\""))
}
@Test
fun emptyStringMinusNumber() {
- assertEquals(-1.0, evalJsInternal("\"\" - 1"))
+ assertEquals(-1.0, num("\"\" - 1"))
}
@Test
@@ -1946,7 +1950,7 @@ class JsInterpreterTest {
@Test
fun postfixIncrementOnNonLvalueFailsGracefully() {
- assertEquals(2.0, evalJsInternal("true+1"))
+ assertEquals(2.0, num("true+1"))
// `true++` has no valid assignment target (a SyntaxError in real JS); evalJs falls
// back to Unit instead of returning a bogus number.
assertEquals(Unit, evalJsInternal("true++"))
@@ -2029,7 +2033,7 @@ class JsInterpreterTest {
@Test
fun combinedCoercionOfNaNEmptyStringAndArrayHoleIsZero() {
- assertEquals(0.0, evalJsInternal("+!!NaN * \"\" - - [,]"))
+ assertEquals(0.0, num("+!!NaN * \"\" - - [,]"))
}
/** Returns a [CoroutineScope] backed by a plain [Job] with no dispatcher attached. */
@@ -2153,7 +2157,6 @@ class JsInterpreterTest {
}
}
}
-
done.send(Unit)
}
@@ -2165,7 +2168,7 @@ class JsInterpreterTest {
@Test
fun newJsContextWithNoInitializerIsUsable() = runTest {
val ctx = newJsContext()
- assertEquals(3.0, ctx.eval("1+2") as? Double ?: 0.0)
+ assertEquals(3.0, ctx.num("1+2"))
}
@Test
@@ -2225,8 +2228,7 @@ class JsInterpreterTest {
fun jsContextFunctionDeclaredInOneEvalUsableInAnother() = runTest {
val ctx = newJsContext()
ctx.eval("function double(n) { return n * 2; }")
- val result = ctx.eval("double(21)")
- assertEquals(42.0, result as? Double ?: 0.0)
+ assertEquals(42.0, ctx.num("double(21)"))
}
@Test
@@ -2234,13 +2236,13 @@ class JsInterpreterTest {
val ctx = newJsContext()
ctx.eval("var arr = [1,2,3]")
ctx.eval("arr.push(4)")
- assertEquals("1,2,3,4", ctx.eval("arr.join(',')") as? String)
+ assertEquals("1,2,3,4", ctx.str("arr.join(',')"))
}
@Test
fun jsContextEvalReturnsLastStatementValue() = runTest {
val ctx = newJsContext()
- assertEquals(9.0, ctx.eval("var a = 3; a * a") as? Double ?: 0.0)
+ assertEquals(9.0, ctx.num("var a = 3; a * a"))
}
@Test
@@ -2252,7 +2254,7 @@ class JsInterpreterTest {
@Test
fun jsContextEvalReturnsStringValue() = runTest {
val ctx = newJsContext()
- assertEquals("ab", ctx.eval("'a' + 'b'"))
+ assertEquals("ab", ctx.str("'a' + 'b'"))
}
@Test
@@ -2267,7 +2269,7 @@ class JsInterpreterTest {
fun separateJsContextsDoNotShareFunctions() = runTest {
val ctx1 = newJsContext { eval("function f(){ return 1; }") }
val ctx2 = newJsContext()
- assertEquals(1.0, ctx1.eval("f()") as? Double ?: 0.0)
+ assertEquals(1.0, ctx1.num("f()"))
// f was never declared in ctx2, so calling it should not crash and yields Unit.
assertEquals(Unit, ctx2.eval("typeof f === 'function' ? f() : undefined"))
}
@@ -2275,8 +2277,7 @@ class JsInterpreterTest {
@Test
fun jsContextEvalDefaultBudgetHandlesNormalScript() = runTest {
val ctx = newJsContext()
- val result = ctx.eval("var s=0; for(var i=1;i<=1000;i++){s+=i} s")
- assertEquals(500500.0, result as? Double ?: 0.0)
+ assertEquals(500500.0, ctx.num("var s=0; for(var i=1;i<=1000;i++){s+=i} s"))
}
@Test
@@ -2312,8 +2313,7 @@ class JsInterpreterTest {
// Abort this call
assertEquals(Unit, ctx.eval("while(true){}"))
// The next call, using the defaults again, runs a normal script fine.
- val result = ctx.eval("1+1")
- assertEquals(2.0, result as? Double ?: 0.0)
+ assertEquals(2.0, ctx.num("1+1"))
}
@Test
@@ -2341,7 +2341,6 @@ class JsInterpreterTest {
}
}
}
-
done.send(Unit)
}
@@ -2354,10 +2353,10 @@ class JsInterpreterTest {
fun jsContextEvalNotCancelledWhenWithTimeoutDoesNotExpire() = runTest {
val ctx = newJsContext()
val result = withTimeoutOrNull(5.seconds) {
- ctx.eval("var s=0; for(var i=0;i<100;i++){s+=i} s")
+ ctx.num("var s=0; for(var i=0;i<100;i++){s+=i} s")
}
- assertEquals(4950.0, result as? Double ?: 0.0)
+ assertEquals(4950.0, result ?: Double.NaN)
}
@Test
@@ -2392,19 +2391,19 @@ class JsInterpreterTest {
@Test
fun jsContextInitializerCanReadBackItsOwnEvalResult() = runTest {
- var capturedDuringInit: Any? = null
+ var capturedDuringInit: Double? = null
newJsContext {
- capturedDuringInit = eval("2 * 21")
+ capturedDuringInit = num("2 * 21")
}
- assertEquals(42.0, capturedDuringInit as? Double ?: 0.0)
+ assertEquals(42.0, capturedDuringInit ?: Double.NaN)
}
@Test
fun jsContextSequentialIndependentComputations() = runTest {
val ctx = newJsContext()
- assertEquals(4.0, ctx.eval("2+2") as? Double ?: 0.0)
- assertEquals("hi", ctx.eval("'h'+'i'") as? String)
- assertFalse(ctx.eval("1 > 2") as? Boolean ?: true)
+ assertEquals(4.0, ctx.num("2+2"))
+ assertEquals("hi", ctx.str("'h'+'i'"))
+ assertFalse(ctx.bool("1 > 2"))
}
}
From 3796406e636356f556c16e04e1da4e37d6a19038 Mon Sep 17 00:00:00 2001
From: Osten <11805592+LagradOst@users.noreply.github.com>
Date: Fri, 10 Jul 2026 01:46:50 +0200
Subject: [PATCH 41/94] Bump to 4.8.0
---
gradle/libs.versions.toml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 8935ab52a..f20c19832 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -63,7 +63,7 @@ compileSdk = "36"
targetSdk = "36"
versionCode = "68"
-versionName = "4.7.0"
+versionName = "4.8.0"
[libraries]
activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activityKtx" }
From 189162fac9183f17fcb26bf3b6b6f09e8001397c Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Fri, 10 Jul 2026 10:12:06 -0600
Subject: [PATCH 42/94] AesHelper: make the new cryptoAESHandler suspend
(#3039)
---
.../cloudstream3/extractors/helper/AesHelper.kt | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/AesHelper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/AesHelper.kt
index 22527bcfb..a09159245 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/AesHelper.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/AesHelper.kt
@@ -9,6 +9,7 @@ import dev.whyoleg.cryptography.CryptographyProvider
import dev.whyoleg.cryptography.DelicateCryptographyApi
import dev.whyoleg.cryptography.algorithms.AES
import dev.whyoleg.cryptography.algorithms.MD5
+import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@@ -21,7 +22,7 @@ object AesHelper {
@OptIn(DelicateCryptographyApi::class)
@Prerelease
- fun cryptoAESHandler(
+ suspend fun cryptoAESHandler(
data: String,
pass: ByteArray,
encrypt: Boolean = true,
@@ -35,14 +36,14 @@ object AesHelper {
saltLength = parse.s.length / 2,
) ?: return null
- val aesKey = aesCbc.keyDecoder().decodeFromByteArrayBlocking(AES.Key.Format.RAW, key)
+ val aesKey = aesCbc.keyDecoder().decodeFromByteArray(AES.Key.Format.RAW, key)
val cipher = aesKey.cipher(padding = padding)
return if (!encrypt) {
- val plainBytes = cipher.decryptWithIvBlocking(iv, base64DecodeArray(parse.ct))
+ val plainBytes = cipher.decryptWithIv(iv, base64DecodeArray(parse.ct))
plainBytes.decodeToString()
} else {
- base64Encode(cipher.encryptWithIvBlocking(iv, parse.ct.encodeToByteArray()))
+ base64Encode(cipher.encryptWithIv(iv, parse.ct.encodeToByteArray()))
}
}
@@ -60,7 +61,7 @@ object AesHelper {
// If it ends with NoPadding (e.g. "AES/CBC/NoPadding"), then it
// doesn't have padding, otherwise we treat as if it does.
val hasPadding = !padding.endsWith("NoPadding")
- return cryptoAESHandler(data, pass, encrypt, hasPadding)
+ return runBlocking { cryptoAESHandler(data, pass, encrypt, hasPadding) }
}
// https://stackoverflow.com/a/41434590/8166854
From b681d443e42b0fd5daa413cc42b7f6ff38181ebb Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Fri, 10 Jul 2026 10:18:30 -0600
Subject: [PATCH 43/94] [skip ci] Make some classes serializable (#2874)
---
.../ui/player/PlayerSubtitleHelper.kt | 33 +++++----
.../cloudstream3/ui/result/ResultFragment.kt | 53 ++++++++-------
.../com/lagradost/cloudstream3/MainAPI.kt | 67 +++++++++++--------
.../cloudstream3/utils/ExtractorApi.kt | 29 ++++----
4 files changed, 102 insertions(+), 80 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt
index ee6170aa5..f62bad58d 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt
@@ -8,11 +8,14 @@ import androidx.annotation.OptIn
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.ui.SubtitleView
+import com.fasterxml.jackson.annotation.JsonIgnore
import com.lagradost.cloudstream3.SubtitleFile
import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.setSubtitleViewStyle
import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF
import com.lagradost.cloudstream3.utils.UIHelper.toPx
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
enum class SubtitleStatus {
IS_ACTIVE,
@@ -32,17 +35,19 @@ enum class SubtitleOrigin {
* @param url Url for the subtitle, when EMBEDDED_IN_VIDEO this variable is used as the real backend id
* @param headers if empty it will use the base onlineDataSource headers else only the specified headers
* @param languageCode usually, tags such as "en", "es-mx", or "zh-hant-TW". But it could be something like "English 4"
- * */
+ */
+@Serializable
data class SubtitleData(
- val originalName: String,
- val nameSuffix: String,
- val url: String,
- val origin: SubtitleOrigin,
- val mimeType: String,
- val headers: Map,
- val languageCode: String?,
+ @SerialName("originalName") val originalName: String,
+ @SerialName("nameSuffix") val nameSuffix: String,
+ @SerialName("url") val url: String,
+ @SerialName("origin") val origin: SubtitleOrigin,
+ @SerialName("mimeType") val mimeType: String,
+ @SerialName("headers") val headers: Map,
+ @SerialName("languageCode") val languageCode: String?,
) {
- /** Internal ID for exoplayer, unique for each link*/
+ /** Internal ID for media3, unique for each link. */
+ @JsonIgnore
fun getId(): String {
return if (origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) url
else "$url|$name"
@@ -54,22 +59,22 @@ data class SubtitleData(
}
/** Tries hard to figure out a valid IETF tag based on language code and name. Will return null if not found. */
+ @JsonIgnore
fun getIETF_tag(): String? {
return fromLanguageToTagIETF(this.languageCode) ?: fromLanguageToTagIETF(this.originalName, halfMatch = true)
}
- val name = "$originalName $nameSuffix"
+ @SerialName("name") val name = "$originalName $nameSuffix"
/**
* Gets the URL, but tries to fix it if it is malformed.
*/
+ @JsonIgnore
fun getFixedUrl(): String {
// Some extensions fail to include the protocol, this helps with that.
val fixedSubUrl = if (this.url.startsWith("//")) {
"https:${this.url}"
- } else {
- this.url
- }
+ } else this.url
return fixedSubUrl
}
}
@@ -142,4 +147,4 @@ class PlayerSubtitleHelper {
setSubStyle(it)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt
index cbf94fd97..71909c5d3 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt
@@ -21,6 +21,8 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos
import com.lagradost.cloudstream3.utils.Event
import com.lagradost.cloudstream3.utils.ImageLoader.loadImage
import com.lagradost.cloudstream3.utils.UiImage
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
const val START_ACTION_RESUME_LATEST = 1
const val START_ACTION_LOAD_EP = 2
@@ -34,33 +36,32 @@ enum class VideoWatchState {
Watched
}
+@Serializable
data class ResultEpisode(
- val headerName: String,
- val name: String?,
- val poster: String?,
- val episode: Int,
- val seasonIndex: Int?, // this is the "season" index used season names
- val season: Int?, // this is the display
- val data: String,
- val apiName: String,
- val id: Int,
- val index: Int,
- val position: Long, // time in MS
- val duration: Long, // duration in MS
- val score: Score?,
- val description: String?,
- val isFiller: Boolean?,
- val tvType: TvType,
- val parentId: Int,
- /**
- * Conveys if the episode itself is marked as watched
- **/
- val videoWatchState: VideoWatchState,
- /** Sum of all previous season episode counts + episode */
- val totalEpisodeIndex: Int? = null,
- val airDate: Long? = null,
- val runTime: Int? = null,
- val seasonData: SeasonData? = null,
+ @SerialName("headerName") val headerName: String,
+ @SerialName("name") val name: String?,
+ @SerialName("poster") val poster: String?,
+ @SerialName("episode") val episode: Int,
+ @SerialName("seasonIndex") val seasonIndex: Int?, // this is the "season" index used season names
+ @SerialName("season") val season: Int?, // this is the display
+ @SerialName("data") val data: String,
+ @SerialName("apiName") val apiName: String,
+ @SerialName("id") val id: Int,
+ @SerialName("index") val index: Int,
+ @SerialName("position") val position: Long, // time in MS
+ @SerialName("duration") val duration: Long, // duration in MS
+ @SerialName("score") val score: Score?,
+ @SerialName("description") val description: String?,
+ @SerialName("isFiller") val isFiller: Boolean?,
+ @SerialName("tvType") val tvType: TvType,
+ @SerialName("parentId") val parentId: Int,
+ /** Conveys if the episode itself is marked as watched. */
+ @SerialName("videoWatchState") val videoWatchState: VideoWatchState,
+ /** Sum of all previous season episode counts + episode. */
+ @SerialName("totalEpisodeIndex") val totalEpisodeIndex: Int? = null,
+ @SerialName("airDate") val airDate: Long? = null,
+ @SerialName("runTime") val runTime: Int? = null,
+ @SerialName("seasonData") val seasonData: SeasonData? = null,
)
fun ResultEpisode.getRealPosition(): Long {
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
index 99421a800..53b773e59 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
@@ -39,6 +39,8 @@ import kotlinx.datetime.format.byUnicodePattern
import kotlinx.datetime.format.char
import kotlinx.datetime.format.parse
import kotlinx.datetime.toInstant
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.io.encoding.Base64
import kotlin.jvm.JvmName
@@ -325,7 +327,7 @@ object APIHolder {
).toJson().toRequestBody(RequestBodyTypes.JSON.toMediaTypeOrNull())
return app.post("https://graphql.anilist.co", requestBody = data)
- .parsedSafe()
+ .parsedSafe()
}
}
@@ -393,18 +395,19 @@ const val PROVIDER_STATUS_SLOW = 2
const val PROVIDER_STATUS_OK = 1
const val PROVIDER_STATUS_DOWN = 0
+@Serializable
data class ProvidersInfoJson(
- @JsonProperty("name") var name: String,
- @JsonProperty("url") var url: String,
- @JsonProperty("credentials") var credentials: String? = null,
- @JsonProperty("status") var status: Int,
+ @JsonProperty("name") @SerialName("name") var name: String,
+ @JsonProperty("url") @SerialName("url") var url: String,
+ @JsonProperty("credentials") @SerialName("credentials") var credentials: String? = null,
+ @JsonProperty("status") @SerialName("status") var status: Int,
)
+@Serializable
data class SettingsJson(
- @JsonProperty("enableAdult") var enableAdult: Boolean = false,
+ @JsonProperty("enableAdult") @SerialName("enableAdult") var enableAdult: Boolean = false,
)
-
data class MainPageData(
val name: String,
val data: String,
@@ -868,10 +871,10 @@ enum class DubStatus(val id: Int) {
* of this as a decimal class specifically for ratings.
* */
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
+@Serializable
class Score private constructor(
/** Decimal between [0, 10^9] representing the min score and max score respectively */
- @JsonProperty("data")
- private val data: Int,
+ @JsonProperty("data") @SerialName("data") private val data: Int,
) {
override fun hashCode(): Int = this.data.hashCode()
override fun equals(other: Any?): Boolean = other is Score && this.data == other.data
@@ -1197,9 +1200,10 @@ suspend fun newSubtitleFile(
* @see newAudioFile
* */
@ConsistentCopyVisibility
+@Serializable
data class AudioFile internal constructor(
- var url: String,
- var headers: Map? = null
+ @JsonProperty("url") @SerialName("url") var url: String,
+ @JsonProperty("headers") @SerialName("headers") var headers: Map? = null,
)
/** Creates an AudioFile with optional initializer for setting additional properties.
@@ -2176,10 +2180,11 @@ data class NextAiring(
* @param name To be shown next to the season like "Season $displaySeason $name" but if displaySeason is null then "$name"
* @param displaySeason What to be displayed next to the season name, if null then the name is the only thing shown.
* */
+@Serializable
data class SeasonData(
- val season: Int,
- val name: String? = null,
- val displaySeason: Int? = null, // will use season if null
+ @JsonProperty("season") @SerialName("season") val season: Int,
+ @JsonProperty("name") @SerialName("name") val name: String? = null,
+ @JsonProperty("displaySeason") @SerialName("displaySeason") val displaySeason: Int? = null, // will use season if null
)
/** Abstract interface of EpisodeResponse */
@@ -2737,32 +2742,38 @@ data class Tracker(
val cover: String? = null,
)
+@Serializable
data class AniSearch(
- @JsonProperty("data") var data: Data? = Data()
+ @JsonProperty("data") @SerialName("data") var data: Data? = Data(),
) {
+ @Serializable
data class Data(
- @JsonProperty("Page") var page: Page? = Page()
+ @JsonProperty("Page") @SerialName("Page") var page: Page? = Page(),
) {
+ @Serializable
data class Page(
- @JsonProperty("media") var media: ArrayList = arrayListOf()
+ @JsonProperty("media") @SerialName("media") var media: ArrayList = arrayListOf(),
) {
+ @Serializable
data class Media(
- @JsonProperty("title") var title: Title? = null,
- @JsonProperty("id") var id: Int? = null,
- @JsonProperty("idMal") var idMal: Int? = null,
- @JsonProperty("seasonYear") var seasonYear: Int? = null,
- @JsonProperty("format") var format: String? = null,
- @JsonProperty("coverImage") var coverImage: CoverImage? = null,
- @JsonProperty("bannerImage") var bannerImage: String? = null,
+ @JsonProperty("title") @SerialName("title") var title: Title? = null,
+ @JsonProperty("id") @SerialName("id") var id: Int? = null,
+ @JsonProperty("idMal") @SerialName("idMal") var idMal: Int? = null,
+ @JsonProperty("seasonYear") @SerialName("seasonYear") var seasonYear: Int? = null,
+ @JsonProperty("format") @SerialName("format") var format: String? = null,
+ @JsonProperty("coverImage") @SerialName("coverImage") var coverImage: CoverImage? = null,
+ @JsonProperty("bannerImage") @SerialName("bannerImage") var bannerImage: String? = null,
) {
+ @Serializable
data class CoverImage(
- @JsonProperty("extraLarge") var extraLarge: String? = null,
- @JsonProperty("large") var large: String? = null,
+ @JsonProperty("extraLarge") @SerialName("extraLarge") var extraLarge: String? = null,
+ @JsonProperty("large") @SerialName("large") var large: String? = null,
)
+ @Serializable
data class Title(
- @JsonProperty("romaji") var romaji: String? = null,
- @JsonProperty("english") var english: String? = null,
+ @JsonProperty("romaji") @SerialName("romaji") var romaji: String? = null,
+ @JsonProperty("english") @SerialName("english") var english: String? = null,
) {
fun isMatchingTitles(title: String?): Boolean {
if (title == null) return false
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt
index 8ef391050..554170df5 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/ExtractorApi.kt
@@ -328,6 +328,9 @@ import io.ktor.http.decodeURLPart
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
import kotlin.coroutines.cancellation.CancellationException
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@@ -418,6 +421,7 @@ enum class ExtractorLinkType {
MAGNET;
// See https://www.iana.org/assignments/media-types/media-types.xhtml
+ @JsonIgnore
fun getMimeType(): String {
return when (this) {
VIDEO -> "video/mp4"
@@ -685,26 +689,27 @@ open class DrmExtractorLink private constructor(
* @property audioTracks List of separate audio tracks that can be used with this video
* @see newExtractorLink
* */
+@Serializable
open class ExtractorLink
@Deprecated("Use newExtractorLink", level = DeprecationLevel.WARNING)
constructor(
- open val source: String,
- open val name: String,
- override val url: String,
- override var referer: String,
- open var quality: Int,
- override var headers: Map = mapOf(),
+ @SerialName("source") open val source: String,
+ @SerialName("name") open val name: String,
+ @SerialName("url") override val url: String,
+ @SerialName("referer") override var referer: String,
+ @SerialName("quality") open var quality: Int,
+ @SerialName("headers") override var headers: Map = mapOf(),
/** Used for getExtractorVerifierJob() */
- open var extractorData: String? = null,
- open var type: ExtractorLinkType,
+ @SerialName("extractorData") open var extractorData: String? = null,
+ @SerialName("type") open var type: ExtractorLinkType,
/** List of separate audio tracks that can be merged with this video */
- open var audioTracks: List = emptyList(),
+ @SerialName("audioTracks") open var audioTracks: List = emptyList(),
) : IDownloadableMinimum {
- val isM3u8: Boolean get() = type == ExtractorLinkType.M3U8
- val isDash: Boolean get() = type == ExtractorLinkType.DASH
+ @get:JsonIgnore val isM3u8: Boolean get() = type == ExtractorLinkType.M3U8
+ @get:JsonIgnore val isDash: Boolean get() = type == ExtractorLinkType.DASH
// Cached video size
- private var videoSize: Long? = null
+ @Transient private var videoSize: Long? = null
/**
* Get video size in bytes with one head request. Only available for ExtractorLinkType.Video
From c36652d265a970f261060a5707293361b278e667 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Fri, 10 Jul 2026 10:40:25 -0600
Subject: [PATCH 44/94] [skip ci] DownloadObjects: migrate to kotlinx
serialization (#3026)
---
.../utils/downloader/DownloadManager.kt | 4 +-
.../utils/downloader/DownloadObjects.kt | 181 ++++++++++--------
2 files changed, 106 insertions(+), 79 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt
index 7cb190667..adefedd20 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt
@@ -1640,11 +1640,11 @@ object VideoDownloadManager {
}
fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? {
- return context.getKey(KEY_RESUME_PACKAGES, id.toString())
+ return context.getKey(KEY_RESUME_PACKAGES, id.toString())
}
fun getDownloadQueuePackage(context: Context, id: Int): DownloadQueueWrapper? {
- return context.getKey(KEY_RESUME_IN_QUEUE, id.toString())
+ return context.getKey(KEY_RESUME_IN_QUEUE, id.toString())
}
fun getDownloadEpisodeMetadata(
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt
index 25a9fdf2a..946e7ecf9 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt
@@ -1,23 +1,32 @@
package com.lagradost.cloudstream3.utils.downloader
import android.net.Uri
+import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.Score
+import com.lagradost.cloudstream3.SkipSerializationTest
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.services.DownloadQueueService
import com.lagradost.cloudstream3.ui.player.SubtitleData
import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.utils.ExtractorLink
+import com.lagradost.cloudstream3.utils.serializers.UriSerializer
+import com.lagradost.cloudstream3.utils.serializers.WriteOnlySerializer
import com.lagradost.safefile.SafeFile
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.KeepGeneratedSerializer
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
import java.io.IOException
import java.io.OutputStream
import java.util.Objects
object DownloadObjects {
/** An item can either be something to resume or something new to start */
+ @Serializable
data class DownloadQueueWrapper(
- @JsonProperty("resumePackage") val resumePackage: DownloadResumePackage?,
- @JsonProperty("downloadItem") val downloadItem: DownloadQueueItem?,
+ @JsonProperty("resumePackage") @SerialName("resumePackage") val resumePackage: DownloadResumePackage?,
+ @JsonProperty("downloadItem") @SerialName("downloadItem") val downloadItem: DownloadQueueItem?,
) {
init {
assert(resumePackage != null || downloadItem != null) {
@@ -26,56 +35,66 @@ object DownloadObjects {
}
/** Loop through the current download instances to see if it is currently downloading. Also includes link loading. */
+ @JsonIgnore
fun isCurrentlyDownloading(): Boolean {
return DownloadQueueService.downloadInstances.value.any { it.downloadQueueWrapper.id == this.id }
}
- @JsonProperty("id")
+ @JsonProperty("id") @SerialName("id")
val id = resumePackage?.item?.ep?.id ?: downloadItem!!.episode.id
- @JsonProperty("parentId")
+ @JsonProperty("parentId") @SerialName("parentId")
val parentId = resumePackage?.item?.ep?.parentId ?: downloadItem!!.episode.parentId
}
/** General data about the episode and show to start a download from. */
+ @Serializable
data class DownloadQueueItem(
- @JsonProperty("episode") val episode: ResultEpisode,
- @JsonProperty("isMovie") val isMovie: Boolean,
- @JsonProperty("resultName") val resultName: String,
- @JsonProperty("resultType") val resultType: TvType,
- @JsonProperty("resultPoster") val resultPoster: String?,
- @JsonProperty("apiName") val apiName: String,
- @JsonProperty("resultId") val resultId: Int,
- @JsonProperty("resultUrl") val resultUrl: String,
- @JsonProperty("links") val links: List? = null,
- @JsonProperty("subs") val subs: List? = null,
+ @JsonProperty("episode") @SerialName("episode") val episode: ResultEpisode,
+ @JsonProperty("isMovie") @SerialName("isMovie") val isMovie: Boolean,
+ @JsonProperty("resultName") @SerialName("resultName") val resultName: String,
+ @JsonProperty("resultType") @SerialName("resultType") val resultType: TvType,
+ @JsonProperty("resultPoster") @SerialName("resultPoster") val resultPoster: String?,
+ @JsonProperty("apiName") @SerialName("apiName") val apiName: String,
+ @JsonProperty("resultId") @SerialName("resultId") val resultId: Int,
+ @JsonProperty("resultUrl") @SerialName("resultUrl") val resultUrl: String,
+ @JsonProperty("links") @SerialName("links") val links: List? = null,
+ @JsonProperty("subs") @SerialName("subs") val subs: List? = null,
) {
fun toWrapper(): DownloadQueueWrapper {
return DownloadQueueWrapper(null, this)
}
}
+ interface DownloadCached {
+ val id: Int
+ }
- abstract class DownloadCached(
- @JsonProperty("id") open val id: Int,
- )
-
+ @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now
+ @KeepGeneratedSerializer
+ @Serializable(with = DownloadEpisodeCached.Serializer::class)
data class DownloadEpisodeCached(
- @JsonProperty("name") val name: String?,
- @JsonProperty("poster") val poster: String?,
- @JsonProperty("episode") val episode: Int,
- @JsonProperty("season") val season: Int?,
- @JsonProperty("parentId") val parentId: Int,
- @JsonProperty("score") var score: Score? = null,
- @JsonProperty("description") val description: String?,
- @JsonProperty("cacheTime") val cacheTime: Long,
- override val id: Int,
- ) : DownloadCached(id) {
+ @JsonProperty("name") @SerialName("name") val name: String?,
+ @JsonProperty("poster") @SerialName("poster") val poster: String?,
+ @JsonProperty("episode") @SerialName("episode") val episode: Int,
+ @JsonProperty("season") @SerialName("season") val season: Int?,
+ @JsonProperty("parentId") @SerialName("parentId") val parentId: Int,
+ @JsonProperty("score") @SerialName("score") var score: Score? = null,
+ @JsonProperty("description") @SerialName("description") val description: String?,
+ @JsonProperty("cacheTime") @SerialName("cacheTime") val cacheTime: Long,
+ @JsonProperty("id") @SerialName("id") override val id: Int,
+ ) : DownloadCached {
+ object Serializer : WriteOnlySerializer(
+ DownloadEpisodeCached.generatedSerializer(),
+ setOf("rating"),
+ )
+
@JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY)
+ @SerialName("rating")
@Deprecated(
"`rating` is the old scoring system, use score instead",
replaceWith = ReplaceWith("score"),
- level = DeprecationLevel.ERROR
+ level = DeprecationLevel.ERROR,
)
var rating: Int? = null
set(value) {
@@ -87,74 +106,81 @@ object DownloadObjects {
}
/** What to display to the user for a downloaded show/movie. Includes info such as name, poster and url */
+ @Serializable
data class DownloadHeaderCached(
- @JsonProperty("apiName") val apiName: String,
- @JsonProperty("url") val url: String,
- @JsonProperty("type") val type: TvType,
- @JsonProperty("name") val name: String,
- @JsonProperty("poster") val poster: String?,
- @JsonProperty("cacheTime") val cacheTime: Long,
- override val id: Int,
- ) : DownloadCached(id)
+ @JsonProperty("apiName") @SerialName("apiName") val apiName: String,
+ @JsonProperty("url") @SerialName("url") val url: String,
+ @JsonProperty("type") @SerialName("type") val type: TvType,
+ @JsonProperty("name") @SerialName("name") val name: String,
+ @JsonProperty("poster") @SerialName("poster") val poster: String?,
+ @JsonProperty("cacheTime") @SerialName("cacheTime") val cacheTime: Long,
+ @JsonProperty("id") @SerialName("id") override val id: Int,
+ ) : DownloadCached
+ @Serializable
data class DownloadResumePackage(
- @JsonProperty("item") val item: DownloadItem,
+ @JsonProperty("item") @SerialName("item") val item: DownloadItem,
/** Tills which link should get resumed */
- @JsonProperty("linkIndex") val linkIndex: Int?,
+ @JsonProperty("linkIndex") @SerialName("linkIndex") val linkIndex: Int?,
) {
fun toWrapper(): DownloadQueueWrapper {
return DownloadQueueWrapper(this, null)
}
}
+ @Serializable
data class DownloadItem(
- @JsonProperty("source") val source: String?,
- @JsonProperty("folder") val folder: String?,
- @JsonProperty("ep") val ep: DownloadEpisodeMetadata,
- @JsonProperty("links") val links: List,
+ @JsonProperty("source") @SerialName("source") val source: String?,
+ @JsonProperty("folder") @SerialName("folder") val folder: String?,
+ @JsonProperty("ep") @SerialName("ep") val ep: DownloadEpisodeMetadata,
+ @JsonProperty("links") @SerialName("links") val links: List,
)
/** Metadata for a specific episode and how to display it. */
+ @Serializable
data class DownloadEpisodeMetadata(
- @JsonProperty("id") val id: Int,
- @JsonProperty("parentId") val parentId: Int,
- @JsonProperty("mainName") val mainName: String,
- @JsonProperty("sourceApiName") val sourceApiName: String?,
- @JsonProperty("poster") val poster: String?,
- @JsonProperty("name") val name: String?,
- @JsonProperty("season") val season: Int?,
- @JsonProperty("episode") val episode: Int?,
- @JsonProperty("type") val type: TvType?,
+ @JsonProperty("id") @SerialName("id") val id: Int,
+ @JsonProperty("parentId") @SerialName("parentId") val parentId: Int,
+ @JsonProperty("mainName") @SerialName("mainName") val mainName: String,
+ @JsonProperty("sourceApiName") @SerialName("sourceApiName") val sourceApiName: String?,
+ @JsonProperty("poster") @SerialName("poster") val poster: String?,
+ @JsonProperty("name") @SerialName("name") val name: String?,
+ @JsonProperty("season") @SerialName("season") val season: Int?,
+ @JsonProperty("episode") @SerialName("episode") val episode: Int?,
+ @JsonProperty("type") @SerialName("type") val type: TvType?,
)
-
+ @Serializable
data class DownloadedFileInfo(
- @JsonProperty("totalBytes") val totalBytes: Long,
- @JsonProperty("relativePath") val relativePath: String,
- @JsonProperty("displayName") val displayName: String,
- @JsonProperty("extraInfo") val extraInfo: String? = null,
- @JsonProperty("basePath") val basePath: String? = null, // null is for legacy downloads. See getBasePath()
+ @JsonProperty("totalBytes") @SerialName("totalBytes") val totalBytes: Long,
+ @JsonProperty("relativePath") @SerialName("relativePath") val relativePath: String,
+ @JsonProperty("displayName") @SerialName("displayName") val displayName: String,
+ @JsonProperty("extraInfo") @SerialName("extraInfo") val extraInfo: String? = null,
+ @JsonProperty("basePath") @SerialName("basePath") val basePath: String? = null, // null is for legacy downloads. See getBasePath()
// Hash of the link associated with this DownloadFile, used so not override old data in the DownloadedFileInfo
- @JsonProperty("linkHash") val linkHash : Int? = null
+ @JsonProperty("linkHash") @SerialName("linkHash") val linkHash: Int? = null,
)
+ @Serializable
+ @SkipSerializationTest // Uri has issues with Jackson
data class DownloadedFileInfoResult(
- @JsonProperty("fileLength") val fileLength: Long,
- @JsonProperty("totalBytes") val totalBytes: Long,
- @JsonProperty("path") val path: Uri,
+ @JsonProperty("fileLength") @SerialName("fileLength") val fileLength: Long,
+ @JsonProperty("totalBytes") @SerialName("totalBytes") val totalBytes: Long,
+ @JsonProperty("path") @SerialName("path")
+ @Serializable(with = UriSerializer::class)
+ val path: Uri,
)
-
+ @Serializable
data class ResumeWatching(
- @JsonProperty("parentId") val parentId: Int,
- @JsonProperty("episodeId") val episodeId: Int?,
- @JsonProperty("episode") val episode: Int?,
- @JsonProperty("season") val season: Int?,
- @JsonProperty("updateTime") val updateTime: Long,
- @JsonProperty("isFromDownload") val isFromDownload: Boolean,
+ @JsonProperty("parentId") @SerialName("parentId") val parentId: Int,
+ @JsonProperty("episodeId") @SerialName("episodeId") val episodeId: Int?,
+ @JsonProperty("episode") @SerialName("episode") val episode: Int?,
+ @JsonProperty("season") @SerialName("season") val season: Int?,
+ @JsonProperty("updateTime") @SerialName("updateTime") val updateTime: Long,
+ @JsonProperty("isFromDownload") @SerialName("isFromDownload") val isFromDownload: Boolean,
)
-
data class DownloadStatus(
/** if you should retry with the same args and hope for a better result */
val retrySame: Boolean,
@@ -164,20 +190,19 @@ object DownloadObjects {
val success: Boolean,
)
-
data class CreateNotificationMetadata(
val type: VideoDownloadManager.DownloadType,
val bytesDownloaded: Long,
val bytesTotal: Long,
val hlsProgress: Long? = null,
val hlsTotal: Long? = null,
- val bytesPerSecond: Long
+ val bytesPerSecond: Long,
)
data class StreamData(
private val fileLength: Long,
val file: SafeFile,
- //val fileStream: OutputStream,
+ // val fileStream: OutputStream,
) {
@Throws(IOException::class)
fun open(): OutputStream {
@@ -198,9 +223,11 @@ object DownloadObjects {
val exists: Boolean get() = file.exists() == true
}
-
- /** bytes have the size end-start where the byte range is [start,end)
- * note that ByteArray is a pointer and therefore cant be stored without cloning it */
+ /**
+ * Bytes have the size end-start where the byte range is [start,end)
+ * note that ByteArray is a pointer and therefore can't be stored
+ * without cloning it.
+ */
data class LazyStreamDownloadResponse(
val bytes: ByteArray,
val startByte: Long,
@@ -221,4 +248,4 @@ object DownloadObjects {
return Objects.hash(startByte, endByte)
}
}
-}
\ No newline at end of file
+}
From e75b5e64a7ea49498d87208542bb38ce468db152 Mon Sep 17 00:00:00 2001
From: Kraptor123 <89366989+Kraptor123@users.noreply.github.com>
Date: Fri, 10 Jul 2026 19:52:23 +0300
Subject: [PATCH 45/94] shortcode: Add py.md as alternative to cutt.ly (#3001)
---
.../cloudstream3/plugins/RepositoryManager.kt | 18 +++++++++++++-----
1 file changed, 13 insertions(+), 5 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
index 5868146c7..7fec67637 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt
@@ -141,11 +141,19 @@ object RepositoryManager {
}
} else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) {
safeAsync {
- val response = app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false)
- val url = response.headers["Location"] ?: return@safeAsync null
- if (url.startsWith("https://cutt.ly/404")) return@safeAsync null
- if (url.removeSuffix("/") == "https://cutt.ly") return@safeAsync null
- return@safeAsync url
+ if (fixedUrl.startsWith("!")) {
+ val response = app.get("https://py.md/${fixedUrl.removePrefix("!")}", allowRedirects = false)
+ val url = response.headers["Location"] ?: return@safeAsync null
+ if (url.startsWith("https://py.md/404")) return@safeAsync null
+ if (url.removeSuffix("/") == "https://py.md") return@safeAsync null
+ return@safeAsync url
+ } else {
+ val response = app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false)
+ val url = response.headers["Location"] ?: return@safeAsync null
+ if (url.startsWith("https://cutt.ly/404")) return@safeAsync null
+ if (url.removeSuffix("/") == "https://cutt.ly") return@safeAsync null
+ return@safeAsync url
+ }
}
} else null
}
From 5b0c5afebad244dded6b47467339b4d0befefeca Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Fri, 10 Jul 2026 10:53:36 -0600
Subject: [PATCH 46/94] EmturbovidExtractor: replace selectXpath (#2977)
This will be necessary when we finally fully migrate to ksoup, which currently doesn't have selectXpath, but for our usages that is not necessary. This way is also more consistent with how we do it everywhere else.
---
.../extractors/EmturbovidExtractor.kt | 24 +++++++++----------
1 file changed, 11 insertions(+), 13 deletions(-)
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/EmturbovidExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/EmturbovidExtractor.kt
index f4a5cdb2b..ca821b21c 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/EmturbovidExtractor.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/EmturbovidExtractor.kt
@@ -8,35 +8,33 @@ import com.lagradost.cloudstream3.utils.Qualities
import com.lagradost.cloudstream3.utils.newExtractorLink
open class EmturbovidExtractor : ExtractorApi() {
- override var name = "Emturbovid"
- override var mainUrl = "https://emturbovid.com"
+ override val name = "Emturbovid"
+ override val mainUrl = "https://emturbovid.com"
override val requiresReferer = false
override suspend fun getUrl(url: String, referer: String?): List? {
- val response = app.get(
- url, referer = referer ?: "$mainUrl/"
- )
- val playerScript =
- response.document.selectXpath("//script[contains(text(),'var urlPlay')]")
- .html()
+ val response = app.get(url, referer = referer ?: "$mainUrl/")
+ val playerScript = response.document
+ .select("script")
+ .first { it.data().contains("var urlPlay") }
+ .html()
val sources = mutableListOf()
if (playerScript.isNotBlank()) {
- val m3u8Url =
- playerScript.substringAfter("var urlPlay = '").substringBefore("'")
-
+ val m3u8Url = playerScript.substringAfter("var urlPlay = '").substringBefore("'")
sources.add(
newExtractorLink(
source = name,
name = name,
url = m3u8Url,
- type = ExtractorLinkType.M3U8
+ type = ExtractorLinkType.M3U8,
) {
this.referer = "$mainUrl/"
this.quality = Qualities.Unknown.value
}
)
}
+
return sources
}
-}
\ No newline at end of file
+}
From 8c3dbdc5b68be080715e8b3c6676cfadd03f51df Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Fri, 10 Jul 2026 10:54:15 -0600
Subject: [PATCH 47/94] Remove fuzzywuzzy from library (#2999)
It doesn't use api(), which means extensions don't inherently have access, and they must already have the dependency explicit to use it. It being in app prevents runtime errors if they use it, but it being an implimentation in the library should be completely unused and unnecessary.
---
library/build.gradle.kts | 3 ---
1 file changed, 3 deletions(-)
diff --git a/library/build.gradle.kts b/library/build.gradle.kts
index 9b769d1e5..979e09844 100644
--- a/library/build.gradle.kts
+++ b/library/build.gradle.kts
@@ -69,9 +69,6 @@ kotlin {
implementation(libs.rhino) // Run JavaScript
implementation(libs.tmdb.java) // TMDB API v3 Wrapper Made with RetroFit
implementation(libs.bundles.cryptography) // Cryptography
-
- // Deprecated; will be removed once extensions have time to migrate from using it
- implementation("me.xdrop:fuzzywuzzy:1.4.0")
}
commonTest.dependencies {
From 3e17b3a5a01b973cd135db259c025c998b7c2680 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Fri, 10 Jul 2026 10:55:35 -0600
Subject: [PATCH 48/94] Make some private cryptography methods suspend (#3040)
---
.../lagradost/cloudstream3/extractors/ByseSX.kt | 6 +++---
.../cloudstream3/extractors/Rabbitstream.kt | 16 ++++++++--------
.../cloudstream3/extractors/helper/GogoHelper.kt | 8 ++++----
3 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt
index 248b94a73..5248a8861 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/ByseSX.kt
@@ -86,15 +86,15 @@ open class ByseSX : ExtractorApi() {
}
@OptIn(DelicateCryptographyApi::class)
- private fun decryptPlayback(playback: Playback): String? {
+ private suspend fun decryptPlayback(playback: Playback): String? {
val keyBytes = buildAesKey(playback)
val ivBytes = b64UrlDecode(playback.iv)
val cipherBytes = b64UrlDecode(playback.payload)
- val aesKey = aesGcm.keyDecoder().decodeFromByteArrayBlocking(AES.Key.Format.RAW, keyBytes)
+ val aesKey = aesGcm.keyDecoder().decodeFromByteArray(AES.Key.Format.RAW, keyBytes)
// 128-bit GCM tag (default)
val cipher = aesKey.cipher()
- val plainBytes = cipher.decryptWithIvBlocking(ivBytes, cipherBytes)
+ val plainBytes = cipher.decryptWithIv(ivBytes, cipherBytes)
var jsonStr = plainBytes.decodeToString()
if (jsonStr.startsWith("\uFEFF")) jsonStr = jsonStr.substring(1)
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Rabbitstream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Rabbitstream.kt
index fe9624430..fdd559258 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Rabbitstream.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Rabbitstream.kt
@@ -133,12 +133,12 @@ open class Rabbitstream : ExtractorApi() {
return extractedKey to sources
}
- private inline fun decryptMapped(input: String, key: String): T? {
+ private inline suspend fun decryptMapped(input: String, key: String): T? {
val decrypt = decrypt(input, key)
return AppUtils.tryParseJson(decrypt)
}
- private fun decrypt(input: String, key: String): String {
+ private suspend fun decrypt(input: String, key: String): String {
return decryptSourceUrl(
generateKey(
salt = base64DecodeArray(input).copyOfRange(8, 16),
@@ -147,7 +147,7 @@ open class Rabbitstream : ExtractorApi() {
)
}
- private fun generateKey(salt: ByteArray, secret: ByteArray): ByteArray {
+ private suspend fun generateKey(salt: ByteArray, secret: ByteArray): ByteArray {
var key = md5(secret + salt)
var currentKey = key
while (currentKey.size < 48) {
@@ -157,18 +157,18 @@ open class Rabbitstream : ExtractorApi() {
return currentKey
}
- private fun md5(input: ByteArray): ByteArray =
- md5Hasher.hashBlocking(input)
+ private suspend fun md5(input: ByteArray): ByteArray =
+ md5Hasher.hash(input)
@OptIn(DelicateCryptographyApi::class)
- private fun decryptSourceUrl(decryptionKey: ByteArray, sourceUrl: String): String {
+ private suspend fun decryptSourceUrl(decryptionKey: ByteArray, sourceUrl: String): String {
val cipherData = base64DecodeArray(sourceUrl)
val encrypted = cipherData.copyOfRange(16, cipherData.size)
val keyBytes = decryptionKey.copyOfRange(0, 32)
val ivBytes = decryptionKey.copyOfRange(32, decryptionKey.size)
- val aesKey = aesCbc.keyDecoder().decodeFromByteArrayBlocking(AES.Key.Format.RAW, keyBytes)
- val decryptedData = aesKey.cipher(padding = true).decryptWithIvBlocking(ivBytes, encrypted)
+ val aesKey = aesCbc.keyDecoder().decodeFromByteArray(AES.Key.Format.RAW, keyBytes)
+ val decryptedData = aesKey.cipher(padding = true).decryptWithIv(ivBytes, encrypted)
return decryptedData.decodeToString()
}
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/GogoHelper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/GogoHelper.kt
index 477c965ce..86d402002 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/GogoHelper.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/GogoHelper.kt
@@ -39,7 +39,7 @@ object GogoHelper {
// https://github.com/saikou-app/saikou/blob/45d0a99b8a72665a29a1eadfb38c506b842a29d7/app/src/main/java/ani/saikou/parsers/anime/extractors/GogoCDN.kt#L97
// No Licence on the function
@OptIn(DelicateCryptographyApi::class)
- private fun cryptoHandler(
+ private suspend fun cryptoHandler(
string: String,
iv: String,
secretKeyString: String,
@@ -47,14 +47,14 @@ object GogoHelper {
): String {
val ivBytes = iv.encodeToByteArray()
val keyBytes = secretKeyString.encodeToByteArray()
- val aesKey = aesCbc.keyDecoder().decodeFromByteArrayBlocking(AES.Key.Format.RAW, keyBytes)
+ val aesKey = aesCbc.keyDecoder().decodeFromByteArray(AES.Key.Format.RAW, keyBytes)
val cipher = aesKey.cipher(padding = true)
return if (!encrypt) {
- val plainBytes = cipher.decryptWithIvBlocking(ivBytes, base64DecodeArray(string))
+ val plainBytes = cipher.decryptWithIv(ivBytes, base64DecodeArray(string))
plainBytes.decodeToString()
} else {
- base64Encode(cipher.encryptWithIvBlocking(ivBytes, string.encodeToByteArray()))
+ base64Encode(cipher.encryptWithIv(ivBytes, string.encodeToByteArray()))
}
}
From 1463cf40cd9c5ad94e089d4107a29895e3fa71dc Mon Sep 17 00:00:00 2001
From: firelight <147925818+fire-light42@users.noreply.github.com>
Date: Fri, 10 Jul 2026 16:57:27 +0000
Subject: [PATCH 49/94] remove prerelease annotations (#3046)
---
.../com/lagradost/cloudstream3/network/RequestsHelper.kt | 2 --
.../commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt | 5 -----
.../kotlin/com/lagradost/cloudstream3/MainActivity.kt | 1 -
.../com/lagradost/cloudstream3/extractors/DoodExtractor.kt | 1 -
.../com/lagradost/cloudstream3/extractors/Firestream.kt | 1 -
.../kotlin/com/lagradost/cloudstream3/extractors/Flyfile.kt | 1 -
.../lagradost/cloudstream3/extractors/StreamWishExtractor.kt | 1 -
.../com/lagradost/cloudstream3/extractors/Streamcash.kt | 1 -
.../kotlin/com/lagradost/cloudstream3/extractors/Vids.kt | 1 -
.../lagradost/cloudstream3/extractors/helper/AesHelper.kt | 1 -
.../cloudstream3/extractors/helper/JWPlayerHelper.kt | 1 -
.../kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt | 1 -
.../kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt | 4 ----
.../kotlin/com/lagradost/cloudstream3/utils/Levenshtein.kt | 1 -
.../kotlin/com/lagradost/cloudstream3/utils/StringUtils.kt | 2 --
.../cloudstream3/utils/serializers/NonEmptySerializer.kt | 1 -
.../cloudstream3/utils/serializers/WriteOnlySerializer.kt | 1 -
17 files changed, 26 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt
index 6234297d0..203a503e9 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt
@@ -22,7 +22,6 @@ fun Requests.initClient(context: Context) {
}
/** Only use ignoreSSL if you know what you are doing*/
-@Prerelease
fun Requests.initClient(context: Context, ignoreSSL: Boolean = false) {
this.baseClient = buildDefaultClient(context, ignoreSSL)
}
@@ -34,7 +33,6 @@ fun buildDefaultClient(context: Context): OkHttpClient {
}
/** Only use ignoreSSL if you know what you are doing*/
-@Prerelease
fun buildDefaultClient(context: Context, ignoreSSL: Boolean = false): OkHttpClient {
safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) }
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
index 53b773e59..fc860c06b 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainAPI.kt
@@ -97,7 +97,6 @@ class ErrorLoadingException(message: String? = null) : Exception(message)
//val baseHeader = mapOf("User-Agent" to USER_AGENT)
-@Prerelease
val json = Json {
encodeDefaults = true
explicitNulls = false
@@ -1094,7 +1093,6 @@ enum class TvType(value: Int?) {
Audio(16),
Podcast(17),
- @Prerelease
Video(18),
}
@@ -2554,12 +2552,10 @@ fun Episode.addDate(date: String?, format: String = "yyyy-MM-dd") {
}.onFailure { logError(it) }.getOrNull()
}
-@Prerelease
fun Episode.addDate(date: LocalDate?) {
this.date = date?.atStartOfDayIn(TimeZone.currentSystemDefault())?.toEpochMilliseconds()
}
-@Prerelease
fun Episode.addDate(date: Instant?) {
this.date = date?.toEpochMilliseconds()
}
@@ -2706,7 +2702,6 @@ fun fetchUrls(text: String?): List {
return linkRegex.findAll(text).map { it.value.trim().removeSurrounding("\"") }.toList()
}
-@Prerelease
fun isUpcoming(dateString: String?): Boolean {
return runCatching {
val fmt = DateTimeComponents.Format {
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt
index 127b075da..dc8317fa0 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/MainActivity.kt
@@ -33,7 +33,6 @@ var app = Requests(responseParser = jsonResponseParser).apply {
/** Same as the default app networking helper, but this instance ignores SSL certificates.
* This should NEVER be used for sensitive networking operations such as logins. Only use this when required. */
-@Prerelease
@UnsafeSSL
var insecureApp = Requests(responseParser = jsonResponseParser).apply {
defaultHeaders = mapOf("user-agent" to USER_AGENT)
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt
index 099406f16..9117d1d78 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/DoodExtractor.kt
@@ -88,7 +88,6 @@ class MyVidPlay : DoodLaExtractor() {
override var mainUrl = "https://myvidplay.com"
}
-@Prerelease
class Playmogo : DoodLaExtractor() {
override var mainUrl = "https://playmogo.com"
}
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Firestream.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Firestream.kt
index c8ef5d0ac..c2fbaeee2 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Firestream.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Firestream.kt
@@ -9,7 +9,6 @@ import com.lagradost.cloudstream3.utils.newExtractorLink
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
-@Prerelease
class Firestream : ExtractorApi() {
override val name: String = "Firestream"
override val mainUrl: String = "https://firestream.to"
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Flyfile.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Flyfile.kt
index eb6d474a5..50042bd19 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Flyfile.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Flyfile.kt
@@ -10,7 +10,6 @@ import com.lagradost.cloudstream3.utils.newExtractorLink
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
-@Prerelease
open class Flyfile : ExtractorApi() {
override val name: String = "FlyFile"
override val mainUrl: String = "https://flyfile.app"
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt
index 58aa25c8c..03c94db9d 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/StreamWishExtractor.kt
@@ -28,7 +28,6 @@ class Ewish : StreamWishExtractor() {
override val mainUrl = "https://embedwish.com"
}
-@Prerelease
class Hgcloudto : StreamWishExtractor() {
override val name = "Hgcloud"
override val mainUrl = "https://Hgcloud.to"
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamcash.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamcash.kt
index 2775c3062..91ccfb2ad 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamcash.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Streamcash.kt
@@ -7,7 +7,6 @@ import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.ExtractorLinkType
import com.lagradost.cloudstream3.utils.newExtractorLink
-@Prerelease
open class Streamcash: ExtractorApi() {
override val name: String = "Streamcash"
override val mainUrl: String = "https://streamcash.to"
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vids.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vids.kt
index 1312570d3..9eb01e321 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vids.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Vids.kt
@@ -7,7 +7,6 @@ import com.lagradost.cloudstream3.utils.ExtractorApi
import com.lagradost.cloudstream3.utils.ExtractorLink
import com.lagradost.cloudstream3.utils.newExtractorLink
-@Prerelease
open class Vids : ExtractorApi() {
override val name: String = "Vids"
override val mainUrl: String = "https://vids.st"
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/AesHelper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/AesHelper.kt
index a09159245..1d2b6bdf3 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/AesHelper.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/AesHelper.kt
@@ -21,7 +21,6 @@ object AesHelper {
private val md5Hasher = provider.get(MD5).hasher()
@OptIn(DelicateCryptographyApi::class)
- @Prerelease
suspend fun cryptoAESHandler(
data: String,
pass: ByteArray,
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/JWPlayerHelper.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/JWPlayerHelper.kt
index c9ba4a624..b026fa237 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/JWPlayerHelper.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/helper/JWPlayerHelper.kt
@@ -13,7 +13,6 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.collections.orEmpty
-@Prerelease
object JwPlayerHelper {
private val sourceRegex = Regex(""""?sources"?:\s*(\[.*?\])""")
private val tracksRegex = Regex(""""?tracks"?:\s*(\[.*?\])""")
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt
index 444709f88..37cda4124 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Coroutines.kt
@@ -68,7 +68,6 @@ object Coroutines {
* If you want to iterate over the list then you need to do:
* list.withLock { code here }
*/
- @Prerelease
fun atomicListOf(vararg items: T): AtomicMutableList {
return AtomicMutableList(items.toMutableList())
}
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt
index 39d950ed1..a8a6344aa 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt
@@ -86,7 +86,6 @@ private const val JS_DEFAULT_MAX_INSTRUCTIONS: Long = 50_000_000L
* Convert any JS runtime value to its JavaScript string representation.
* Mirrors what JS `String(value)` would produce.
*/
-@Prerelease
fun jsValueToString(v: Any?): String = toJsString(v)
/**
@@ -106,7 +105,6 @@ fun jsValueToString(v: Any?): String = toJsString(v)
* @param scope the [CoroutineScope] this context's cancellation is tied to. Supplied
* automatically by [newJsContext] from the caller's own coroutine context.
*/
-@Prerelease
class JsContext internal constructor(
var maxExecutionTime: Duration = JS_DEFAULT_MAX_EXECUTION_TIME,
var maxInstructions: Long = JS_DEFAULT_MAX_INSTRUCTIONS,
@@ -160,7 +158,6 @@ class JsContext internal constructor(
* eval("x + 1")
* }
*/
-@Prerelease
suspend fun newJsContext(
initializer: suspend JsContext.() -> Unit = {},
): JsContext {
@@ -188,7 +185,6 @@ suspend fun newJsContext(
* Returns [Unit] on evaluation failure, timeout, or when the result is JS undefined.
* JS null is represented as Kotlin null. Use [jsValueToString] to convert to a JS string.
*/
-@Prerelease
@Throws(CancellationException::class)
suspend fun evalJs(
js: String,
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Levenshtein.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Levenshtein.kt
index 2f3957630..82fb3af23 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Levenshtein.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/Levenshtein.kt
@@ -28,7 +28,6 @@ import com.lagradost.cloudstream3.Prerelease
import kotlin.math.round
// Taken from https://github.com/terrakok/FuzzyKot/blob/f794d43/fuzzykot/src/commonMain/kotlin/com/github/terrakok/fuzzykot/Levenshtein.kt
-@Prerelease
object Levenshtein {
fun ratio(s1: String, s2: String, processor: (String) -> String = { it }): Int {
val p1 = processor(s1)
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/StringUtils.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/StringUtils.kt
index 64815f794..8b2fa752e 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/StringUtils.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/StringUtils.kt
@@ -5,12 +5,10 @@ import io.ktor.http.decodeURLQueryComponent
import io.ktor.http.encodeURLParameter
object StringUtils {
- @Prerelease
fun String.decodeUrl(): String {
return this.decodeURLQueryComponent()
}
- @Prerelease
fun String.encodeUrl(): String {
return this.encodeURLParameter()
}
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NonEmptySerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NonEmptySerializer.kt
index 82de9f7f7..62b306e1f 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NonEmptySerializer.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/NonEmptySerializer.kt
@@ -28,7 +28,6 @@ import kotlinx.serialization.json.JsonTransformingSerializer
* object Serializer : NonEmptySerializer(MyData.generatedSerializer())
* }
*/
-@Prerelease
abstract class NonEmptySerializer(tSerializer: KSerializer) :
JsonTransformingSerializer(tSerializer) {
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/WriteOnlySerializer.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/WriteOnlySerializer.kt
index c7f412eaa..1d06b7fca 100644
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/WriteOnlySerializer.kt
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/serializers/WriteOnlySerializer.kt
@@ -25,7 +25,6 @@ import kotlinx.serialization.json.JsonTransformingSerializer
* )
* }
*/
-@Prerelease
abstract class WriteOnlySerializer(
tSerializer: KSerializer,
private val keysToIgnore: Set,
From 0af9b7f569fa0bc177ee04d1c3a38848ee4f4151 Mon Sep 17 00:00:00 2001
From: Luna712 <142361265+Luna712@users.noreply.github.com>
Date: Fri, 10 Jul 2026 10:59:59 -0600
Subject: [PATCH 50/94] DataStoreHelper: migrate to kotlinx serialization
(#3027)
---
.../cloudstream3/utils/DataStoreHelper.kt | 326 ++++++++++--------
1 file changed, 185 insertions(+), 141 deletions(-)
diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt
index 19caead21..ec896facb 100644
--- a/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt
+++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt
@@ -1,6 +1,7 @@
package com.lagradost.cloudstream3.utils
import android.content.Context
+import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.APIHolder.unixTimeMS
import com.lagradost.cloudstream3.CloudStreamApp.Companion.context
@@ -31,6 +32,12 @@ import com.lagradost.cloudstream3.ui.result.ResultEpisode
import com.lagradost.cloudstream3.ui.result.VideoWatchState
import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia
import com.lagradost.cloudstream3.utils.downloader.DownloadObjects
+import com.lagradost.cloudstream3.utils.serializers.WriteOnlySerializer
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.KeepGeneratedSerializer
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
import java.util.Calendar
import java.util.Date
import java.util.GregorianCalendar
@@ -43,17 +50,18 @@ const val RESULT_WATCH_STATE = "result_watch_state"
const val RESULT_WATCH_STATE_DATA = "result_watch_state_data"
const val RESULT_SUBSCRIBED_STATE_DATA = "result_subscribed_state_data"
const val RESULT_FAVORITES_STATE_DATA = "result_favorites_state_data"
-const val RESULT_RESUME_WATCHING = "result_resume_watching_2" // changed due to id changes
+const val RESULT_RESUME_WATCHING = "result_resume_watching_2" // Changed due to id changes
const val RESULT_RESUME_WATCHING_OLD = "result_resume_watching"
const val RESULT_RESUME_WATCHING_HAS_MIGRATED = "result_resume_watching_migrated"
const val RESULT_EPISODE = "result_episode"
const val RESULT_SEASON = "result_season"
const val RESULT_DUB = "result_dub"
const val KEY_RESULT_SORT = "result_sort"
-const val USER_PINNED_PROVIDERS = "user_pinned_providers" //key for pinned user set
+const val USER_PINNED_PROVIDERS = "user_pinned_providers" // Key for pinned user set
class UserPreferenceDelegate(
- private val key: String, private val default: T //, private val klass: KClass
+ private val key: String,
+ private val default: T,
) {
private val klass: KClass = default::class
private val realKey get() = "${DataStoreHelper.currentAccount}/$key"
@@ -63,7 +71,7 @@ class UserPreferenceDelegate(
operator fun setValue(
self: Any?,
property: KProperty<*>,
- t: T?
+ t: T?,
) {
if (t == null) {
removeKey(realKey)
@@ -82,7 +90,7 @@ object DataStoreHelper {
R.drawable.profile_bg_pink,
R.drawable.profile_bg_purple,
R.drawable.profile_bg_red,
- R.drawable.profile_bg_teal
+ R.drawable.profile_bg_teal,
)
private var searchPreferenceProvidersStrings: List by UserPreferenceDelegate(
@@ -112,16 +120,17 @@ object DataStoreHelper {
private var searchPreferenceTagsStrings: List by UserPreferenceDelegate(
"search_pref_tags",
listOf(TvType.Movie, TvType.TvSeries).map { it.name })
+
var searchPreferenceTags: List
get() = deserializeTv(searchPreferenceTagsStrings)
set(value) {
searchPreferenceTagsStrings = serializeTv(value)
}
-
private var homePreferenceStrings: List by UserPreferenceDelegate(
"home_pref_homepage",
listOf(TvType.Movie, TvType.TvSeries).map { it.name })
+
var homePreference: List
get() = deserializeTv(homePreferenceStrings)
set(value) {
@@ -132,38 +141,38 @@ object DataStoreHelper {
"home_bookmarked_last_list",
IntArray(0)
)
+
var playBackSpeed: Float by UserPreferenceDelegate("playback_speed", 1.0f)
var resizeMode: Int by UserPreferenceDelegate("resize_mode", 0)
var librarySortingMode: Int by UserPreferenceDelegate(
"library_sorting_mode",
ListSorting.AlphabeticalA.ordinal
)
+
private var _resultsSortingMode: Int by UserPreferenceDelegate(
"results_sorting_mode",
EpisodeSortType.NUMBER_ASC.ordinal
)
+
var resultsSortingMode: EpisodeSortType
get() = EpisodeSortType.entries.getOrNull(_resultsSortingMode) ?: EpisodeSortType.NUMBER_ASC
set(value) {
_resultsSortingMode = value.ordinal
}
+ @Serializable
data class Account(
- @JsonProperty("keyIndex")
- val keyIndex: Int,
- @JsonProperty("name")
- val name: String,
- @JsonProperty("customImage")
- val customImage: String? = null,
- @JsonProperty("defaultImageIndex")
- val defaultImageIndex: Int,
- @JsonProperty("lockPin")
- val lockPin: String? = null,
+ @JsonProperty("keyIndex") @SerialName("keyIndex") val keyIndex: Int,
+ @JsonProperty("name") @SerialName("name") val name: String,
+ @JsonProperty("customImage") @SerialName("customImage") val customImage: String? = null,
+ @JsonProperty("defaultImageIndex") @SerialName("defaultImageIndex") val defaultImageIndex: Int,
+ @JsonProperty("lockPin") @SerialName("lockPin") val lockPin: String? = null,
) {
- val image
- get() = customImage?.let { UiImage.Image(it) } ?: profileImages.getOrNull(
- defaultImageIndex
- )?.let { UiImage.Drawable(it) } ?: UiImage.Drawable(profileImages.first())
+ @get:JsonIgnore
+ val image get() = customImage?.let { UiImage.Image(it) } ?:
+ profileImages.getOrNull(defaultImageIndex)?.let {
+ UiImage.Drawable(it)
+ } ?: UiImage.Drawable(profileImages.first())
}
const val TAG = "data_store_helper"
@@ -188,7 +197,6 @@ object DataStoreHelper {
fun setAccount(account: Account) {
val homepage = currentHomePage
-
selectedKeyIndex = account.keyIndex
AccountManager.updateAccountIds()
showToast(context?.getString(R.string.logged_account, account.name) ?: account.name)
@@ -206,7 +214,7 @@ object DataStoreHelper {
currentAccounts.getOrNull(currentAccounts.indexOfFirst { it.keyIndex == 0 }) ?: Account(
keyIndex = 0,
name = context.getString(R.string.default_account),
- defaultImageIndex = 0
+ defaultImageIndex = 0,
)
}
}
@@ -232,18 +240,21 @@ object DataStoreHelper {
}
}
+ @Serializable
data class PosDur(
- @JsonProperty("position") val position: Long,
- @JsonProperty("duration") val duration: Long
+ @JsonProperty("position") @SerialName("position") val position: Long,
+ @JsonProperty("duration") @SerialName("duration") val duration: Long,
)
fun PosDur.fixVisual(): PosDur {
if (duration <= 0) return PosDur(0, duration)
val percentage = position * 100 / duration
- if (percentage <= 1) return PosDur(0, duration)
- if (percentage <= 5) return PosDur(5 * duration / 100, duration)
- if (percentage >= 95) return PosDur(duration, duration)
- return this
+ return when {
+ percentage <= 1 -> PosDur(0, duration)
+ percentage <= 5 -> PosDur(5 * duration / 100, duration)
+ percentage >= 95 -> PosDur(duration, duration)
+ else -> this
+ }
}
fun Int.toYear(): Date =
@@ -251,28 +262,38 @@ object DataStoreHelper {
/**
* Used to display notifications on new episodes and posters in library.
- **/
+ */
+ @Serializable
abstract class LibrarySearchResponse(
- @JsonProperty("id") override var id: Int?,
- @JsonProperty("latestUpdatedTime") open val latestUpdatedTime: Long,
- @JsonProperty("name") override val name: String,
- @JsonProperty("url") override val url: String,
- @JsonProperty("apiName") override val apiName: String,
- @JsonProperty("type") override var type: TvType?,
- @JsonProperty("posterUrl") override var posterUrl: String?,
- @JsonProperty("year") open val year: Int?,
- @JsonProperty("syncData") open val syncData: Map?,
- @JsonProperty("quality") override var quality: SearchQuality?,
- @JsonProperty("posterHeaders") override var posterHeaders: Map?,
- @JsonProperty("plot") open val plot: String? = null,
- @JsonProperty("score") override var score: Score? = null,
- @JsonProperty("tags") open val tags: List? = null,
+ /**
+ * These fields are marked @Transient because this class is only ever serialized through
+ * through its subclasses, which redeclare each property with their own @SerialName
+ * annotations. Without @Transient here, kotlinx.serialization would try to
+ * generate a serializer for the abstract base class itself (or double-serialize
+ * these fields), which fails/conflicts since these are meant to be overridden,
+ * not serialized directly from the parent.
+ */
+ @Transient override var id: Int? = null,
+ @Transient open val latestUpdatedTime: Long = 0L,
+ @Transient override val name: String = "",
+ @Transient override val url: String = "",
+ @Transient override val apiName: String = "",
+ @Transient override var type: TvType? = null,
+ @Transient override var posterUrl: String? = null,
+ @Transient open val year: Int? = null,
+ @Transient open val syncData: Map? = null,
+ @Transient override var quality: SearchQuality? = null,
+ @Transient override var posterHeaders: Map? = null,
+ @Transient open val plot: String? = null,
+ @Transient override var score: Score? = null,
+ @Transient open val tags: List? = null,
) : SearchResponse {
@JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY)
+ @SerialName("rating")
@Deprecated(
"`rating` is the old scoring system, use score instead",
replaceWith = ReplaceWith("score"),
- level = DeprecationLevel.ERROR
+ level = DeprecationLevel.ERROR,
)
var rating: Int? = null
set(value) {
@@ -283,23 +304,26 @@ object DataStoreHelper {
}
}
+ @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now
+ @KeepGeneratedSerializer
+ @Serializable(with = SubscribedData.Serializer::class)
data class SubscribedData(
- @JsonProperty("subscribedTime") val subscribedTime: Long,
- @JsonProperty("lastSeenEpisodeCount") val lastSeenEpisodeCount: Map,
- override var id: Int?,
- override val latestUpdatedTime: Long,
- override val name: String,
- override val url: String,
- override val apiName: String,
- override var type: TvType?,
- override var posterUrl: String?,
- override val year: Int?,
- override val syncData: Map? = null,
- override var quality: SearchQuality? = null,
- override var posterHeaders: Map? = null,
- override val plot: String? = null,
- override var score: Score? = null,
- override val tags: List? = null,
+ @JsonProperty("subscribedTime") @SerialName("subscribedTime") val subscribedTime: Long,
+ @JsonProperty("lastSeenEpisodeCount") @SerialName("lastSeenEpisodeCount") val lastSeenEpisodeCount: Map,
+ @JsonProperty("id") @SerialName("id") override var id: Int?,
+ @JsonProperty("latestUpdatedTime") @SerialName("latestUpdatedTime") override val latestUpdatedTime: Long,
+ @JsonProperty("name") @SerialName("name") override val name: String,
+ @JsonProperty("url") @SerialName("url") override val url: String,
+ @JsonProperty("apiName") @SerialName("apiName") override val apiName: String,
+ @JsonProperty("type") @SerialName("type") override var type: TvType?,
+ @JsonProperty("posterUrl") @SerialName("posterUrl") override var posterUrl: String?,
+ @JsonProperty("year") @SerialName("year") override val year: Int?,
+ @JsonProperty("syncData") @SerialName("syncData") override val syncData: Map? = null,
+ @JsonProperty("quality") @SerialName("quality") override var quality: SearchQuality? = null,
+ @JsonProperty("posterHeaders") @SerialName("posterHeaders") override var posterHeaders: Map? = null,
+ @JsonProperty("plot") @SerialName("plot") override val plot: String? = null,
+ @JsonProperty("score") @SerialName("score") override var score: Score? = null,
+ @JsonProperty("tags") @SerialName("tags") override val tags: List? = null,
) : LibrarySearchResponse(
id,
latestUpdatedTime,
@@ -314,8 +338,13 @@ object DataStoreHelper {
posterHeaders,
plot,
score,
- tags
+ tags,
) {
+ object Serializer : WriteOnlySerializer(
+ SubscribedData.generatedSerializer(),
+ setOf("rating"),
+ )
+
fun toLibraryItem(): SyncAPI.LibraryItem? {
return SyncAPI.LibraryItem(
name,
@@ -334,27 +363,30 @@ object DataStoreHelper {
this.id,
plot = this.plot,
score = this.score,
- tags = this.tags
+ tags = this.tags,
)
}
}
+ @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now
+ @KeepGeneratedSerializer
+ @Serializable(with = BookmarkedData.Serializer::class)
data class BookmarkedData(
- @JsonProperty("bookmarkedTime") val bookmarkedTime: Long,
- override var id: Int?,
- override val latestUpdatedTime: Long,
- override val name: String,
- override val url: String,
- override val apiName: String,
- override var type: TvType?,
- override var posterUrl: String?,
- override val year: Int?,
- override val syncData: Map? = null,
- override var quality: SearchQuality? = null,
- override var posterHeaders: Map? = null,
- override val plot: String? = null,
- override var score: Score? = null,
- override val tags: List? = null,
+ @JsonProperty("bookmarkedTime") @SerialName("bookmarkedTime") val bookmarkedTime: Long,
+ @JsonProperty("id") @SerialName("id") override var id: Int?,
+ @JsonProperty("latestUpdatedTime") @SerialName("latestUpdatedTime") override val latestUpdatedTime: Long,
+ @JsonProperty("name") @SerialName("name") override val name: String,
+ @JsonProperty("url") @SerialName("url") override val url: String,
+ @JsonProperty("apiName") @SerialName("apiName") override val apiName: String,
+ @JsonProperty("type") @SerialName("type") override var type: TvType?,
+ @JsonProperty("posterUrl") @SerialName("posterUrl") override var posterUrl: String?,
+ @JsonProperty("year") @SerialName("year") override val year: Int?,
+ @JsonProperty("syncData") @SerialName("syncData") override val syncData: Map? = null,
+ @JsonProperty("quality") @SerialName("quality") override var quality: SearchQuality? = null,
+ @JsonProperty("posterHeaders") @SerialName("posterHeaders") override var posterHeaders: Map? = null,
+ @JsonProperty("plot") @SerialName("plot") override val plot: String? = null,
+ @JsonProperty("score") @SerialName("score") override var score: Score? = null,
+ @JsonProperty("tags") @SerialName("tags") override val tags: List? = null,
) : LibrarySearchResponse(
id,
latestUpdatedTime,
@@ -367,8 +399,13 @@ object DataStoreHelper {
syncData,
quality,
posterHeaders,
- plot
+ plot,
) {
+ object Serializer : WriteOnlySerializer(
+ BookmarkedData.generatedSerializer(),
+ setOf("rating"),
+ )
+
fun toLibraryItem(id: String): SyncAPI.LibraryItem {
return SyncAPI.LibraryItem(
name,
@@ -387,27 +424,30 @@ object DataStoreHelper {
this.id,
plot = this.plot,
score = this.score,
- tags = this.tags
+ tags = this.tags,
)
}
}
+ @OptIn(ExperimentalSerializationApi::class) // KeepGeneratedSerializer is an experimental annotation for now
+ @KeepGeneratedSerializer
+ @Serializable(with = FavoritesData.Serializer::class)
data class FavoritesData(
- @JsonProperty("favoritesTime") val favoritesTime: Long,
- override var id: Int?,
- override val latestUpdatedTime: Long,
- override val name: String,
- override val url: String,
- override val apiName: String,
- override var type: TvType?,
- override var posterUrl: String?,
- override val year: Int?,
- override val syncData: Map