From 30df73645beb00d41401d1cd830b2ca5eac643fd Mon Sep 17 00:00:00 2001 From: Cloudburst <18114966+C10udburst@users.noreply.github.com> Date: Thu, 22 Jan 2026 15:25:09 +0100 Subject: [PATCH 1/2] initial server testing --- gradle/libs.versions.toml | 12 + server/.gitignore | 2 + server/build.gradle.kts | 59 + .../main/kotlin/android/content/Context.kt | 106 + .../android/content/SharedPreferences.kt | 32 + .../kotlin/android/content/res/Resources.kt | 3 + server/src/main/kotlin/android/net/Uri.kt | 10 + server/src/main/kotlin/android/util/Log.kt | 8 + .../com/lagradost/cloudstream3/ConfigStore.kt | 57 + .../com/lagradost/cloudstream3/Models.kt | 259 +++ .../cloudstream3/ProviderRegistry.kt | 60 + .../com/lagradost/cloudstream3/Server.kt | 874 +++++++ .../lagradost/cloudstream3/ServerContext.kt | 173 ++ .../cloudstream3/actions/VideoClickAction.kt | 11 + .../lagradost/cloudstream3/plugins/Plugin.kt | 24 + .../cloudstream3/plugins/PluginData.kt | 9 + .../cloudstream3/plugins/PluginManager.kt | 265 +++ .../cloudstream3/plugins/RepositoryManager.kt | 88 + .../cloudstream3/plugins/VotingApi.kt | 31 + .../syncproviders/AccountManager.kt | 67 + .../cloudstream3/syncproviders/AuthAPI.kt | 31 + .../cloudstream3/syncproviders/AuthModels.kt | 53 + .../cloudstream3/syncproviders/AuthRepo.kt | 30 + .../cloudstream3/syncproviders/SubtitleAPI.kt | 3 + .../syncproviders/SubtitleRepo.kt | 3 + .../cloudstream3/syncproviders/SyncAPI.kt | 34 + .../cloudstream3/syncproviders/SyncRepo.kt | 26 + .../syncproviders/providers/Providers.kt | 49 + server/ui/.gitignore | 24 + server/ui/README.md | 47 + server/ui/index.html | 13 + server/ui/package-lock.json | 2029 +++++++++++++++++ server/ui/package.json | 27 + server/ui/src/App.svelte | 43 + server/ui/src/api/index.ts | 114 + server/ui/src/app.css | 34 + server/ui/src/assets/svelte.svg | 1 + .../ui/src/components/layout/Sidebar.svelte | 54 + .../components/layout/ToastContainer.svelte | 23 + .../src/components/shared/ConfirmModal.svelte | 44 + .../src/components/shared/PosterCard.svelte | 36 + server/ui/src/main.ts | 9 + server/ui/src/pages/Home.svelte | 143 ++ server/ui/src/pages/PluginManager.svelte | 495 ++++ server/ui/src/pages/Search.svelte | 127 ++ server/ui/src/pages/Settings.svelte | 152 ++ server/ui/src/stores/index.ts | 32 + server/ui/src/stores/theme.ts | 42 + server/ui/src/stores/toast.ts | 38 + server/ui/svelte.config.js | 8 + server/ui/tsconfig.app.json | 21 + server/ui/tsconfig.json | 7 + server/ui/tsconfig.node.json | 26 + server/ui/vite.config.ts | 20 + settings.gradle.kts | 2 +- 55 files changed, 5989 insertions(+), 1 deletion(-) create mode 100644 server/.gitignore create mode 100644 server/build.gradle.kts create mode 100644 server/src/main/kotlin/android/content/Context.kt create mode 100644 server/src/main/kotlin/android/content/SharedPreferences.kt create mode 100644 server/src/main/kotlin/android/content/res/Resources.kt create mode 100644 server/src/main/kotlin/android/net/Uri.kt create mode 100644 server/src/main/kotlin/android/util/Log.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/ConfigStore.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/Models.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/ProviderRegistry.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/Server.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/ServerContext.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/actions/VideoClickAction.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/plugins/PluginData.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/plugins/PluginManager.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/plugins/RepositoryManager.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/plugins/VotingApi.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/AccountManager.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/AuthModels.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/providers/Providers.kt create mode 100644 server/ui/.gitignore create mode 100644 server/ui/README.md create mode 100644 server/ui/index.html create mode 100644 server/ui/package-lock.json create mode 100644 server/ui/package.json create mode 100644 server/ui/src/App.svelte create mode 100644 server/ui/src/api/index.ts create mode 100644 server/ui/src/app.css create mode 100644 server/ui/src/assets/svelte.svg create mode 100644 server/ui/src/components/layout/Sidebar.svelte create mode 100644 server/ui/src/components/layout/ToastContainer.svelte create mode 100644 server/ui/src/components/shared/ConfirmModal.svelte create mode 100644 server/ui/src/components/shared/PosterCard.svelte create mode 100644 server/ui/src/main.ts create mode 100644 server/ui/src/pages/Home.svelte create mode 100644 server/ui/src/pages/PluginManager.svelte create mode 100644 server/ui/src/pages/Search.svelte create mode 100644 server/ui/src/pages/Settings.svelte create mode 100644 server/ui/src/stores/index.ts create mode 100644 server/ui/src/stores/theme.ts create mode 100644 server/ui/src/stores/toast.ts create mode 100644 server/ui/svelte.config.js create mode 100644 server/ui/tsconfig.app.json create mode 100644 server/ui/tsconfig.json create mode 100644 server/ui/tsconfig.node.json create mode 100644 server/ui/vite.config.ts diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2ffddc862..8adb8aff8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,13 +19,16 @@ fuzzywuzzy = "1.4.0" jacksonModuleKotlin = { strictly = "2.13.1" } # Later versions don't support minSdk <26 (Crashes on Android TV's and FireSticks) json = "20251224" jsoup = "1.21.2" +dex2jar = "2.4.34" junit = "4.13.2" junitKtx = "1.3.0" junitVersion = "1.3.0" juniversalchardet = "2.5.0" kotlinGradlePlugin = "2.3.0" kotlinxCoroutinesCore = "1.10.2" +ktor = "2.3.12" lifecycleKtx = "2.9.4" +logback = "1.5.13" material = "1.14.0-alpha08" media3 = "1.8.0" navigationKtx = "2.9.6" @@ -72,14 +75,23 @@ ext-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" } fuzzywuzzy = { module = "me.xdrop:fuzzywuzzy", version.ref = "fuzzywuzzy" } jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } +dex2jar = { module = "de.femtopedia.dex2jar:dex2jar", version.ref = "dex2jar" } json = { module = "org.json:json", version.ref = "json" } 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" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging-jvm", version.ref = "ktor" } +ktor-server-content-negotiation = { module = "io.ktor:ktor-server-content-negotiation-jvm", version.ref = "ktor" } +ktor-server-core = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" } +ktor-server-cors = { module = "io.ktor:ktor-server-cors-jvm", version.ref = "ktor" } +ktor-server-netty = { module = "io.ktor:ktor-server-netty-jvm", version.ref = "ktor" } +ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages-jvm", version.ref = "ktor" } +ktor-serialization-jackson = { module = "io.ktor:ktor-serialization-jackson-jvm", version.ref = "ktor" } lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleKtx" } lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleKtx" } +logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } material = { module = "com.google.android.material:material", version.ref = "material" } media3-cast = { module = "androidx.media3:media3-cast", version.ref = "media3" } media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" } diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 000000000..376a725a9 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,2 @@ +data +config.json \ No newline at end of file diff --git a/server/build.gradle.kts b/server/build.gradle.kts new file mode 100644 index 000000000..b6c15cbd2 --- /dev/null +++ b/server/build.gradle.kts @@ -0,0 +1,59 @@ +import org.gradle.api.JavaVersion +import org.gradle.api.file.DuplicatesStrategy +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.api.tasks.bundling.Tar +import org.gradle.api.tasks.bundling.Zip +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile + +plugins { + application + alias(libs.plugins.kotlin.jvm) +} + +kotlin { + jvmToolchain(libs.versions.jdkToolchain.get().toInt()) +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get().toInt())) + } + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 +} + +dependencies { + implementation(project(":library")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.dex2jar) + implementation(libs.nicehttp) + implementation(libs.jackson.module.kotlin) + implementation(libs.ktor.server.call.logging) + implementation(libs.ktor.server.content.negotiation) + implementation(libs.ktor.server.core) + implementation(libs.ktor.server.cors) + implementation(libs.ktor.server.netty) + implementation(libs.ktor.server.status.pages) + implementation(libs.ktor.serialization.jackson) + runtimeOnly(libs.logback.classic) +} + +application { + mainClass.set("com.lagradost.cloudstream3.ServerKt") +} + +tasks.withType { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + freeCompilerArgs.add("-opt-in=com.lagradost.cloudstream3.Prerelease") + } +} + +tasks.named("distTar") { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +tasks.named("distZip") { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} diff --git a/server/src/main/kotlin/android/content/Context.kt b/server/src/main/kotlin/android/content/Context.kt new file mode 100644 index 000000000..26aa3e753 --- /dev/null +++ b/server/src/main/kotlin/android/content/Context.kt @@ -0,0 +1,106 @@ +package android.content + +import java.io.File + +open class Context { + private val prefs = InMemorySharedPreferences() + + open fun getSharedPreferences(name: String, mode: Int): SharedPreferences = prefs + + open fun getApplicationContext(): Context = this + + open fun getFilesDir(): File = File(".") +} + +private class InMemorySharedPreferences : SharedPreferences { + private val data = mutableMapOf() + + override fun getAll(): Map = data.toMap() + + override fun getString(key: String, defValue: String?): String? = + data[key] as? String ?: defValue + + override fun getStringSet(key: String, defValues: Set?): Set? = + @Suppress("UNCHECKED_CAST") (data[key] as? Set) ?: defValues + + override fun getInt(key: String, defValue: Int): Int = + data[key] as? Int ?: defValue + + override fun getLong(key: String, defValue: Long): Long = + data[key] as? Long ?: defValue + + override fun getFloat(key: String, defValue: Float): Float = + data[key] as? Float ?: defValue + + override fun getBoolean(key: String, defValue: Boolean): Boolean = + data[key] as? Boolean ?: defValue + + override fun contains(key: String): Boolean = data.containsKey(key) + + override fun edit(): SharedPreferences.Editor = Editor(data) + + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener) { + } + + override fun unregisterOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener + ) { + } + + private class Editor(private val data: MutableMap) : SharedPreferences.Editor { + private val pending = mutableMapOf() + private val removals = mutableSetOf() + private var clear = false + + override fun putString(key: String, value: String?): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun putStringSet(key: String, values: Set?): SharedPreferences.Editor { + pending[key] = values + return this + } + + override fun putInt(key: String, value: Int): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun putLong(key: String, value: Long): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun putFloat(key: String, value: Float): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun remove(key: String): SharedPreferences.Editor { + removals.add(key) + return this + } + + override fun clear(): SharedPreferences.Editor { + clear = true + return this + } + + override fun commit(): Boolean { + apply() + return true + } + + override fun apply() { + if (clear) data.clear() + removals.forEach { data.remove(it) } + data.putAll(pending) + } + } +} diff --git a/server/src/main/kotlin/android/content/SharedPreferences.kt b/server/src/main/kotlin/android/content/SharedPreferences.kt new file mode 100644 index 000000000..b691582c1 --- /dev/null +++ b/server/src/main/kotlin/android/content/SharedPreferences.kt @@ -0,0 +1,32 @@ +package android.content + +interface SharedPreferences { + fun getAll(): Map + fun getString(key: String, defValue: String?): String? + fun getStringSet(key: String, defValues: Set?): Set? + fun getInt(key: String, defValue: Int): Int + fun getLong(key: String, defValue: Long): Long + fun getFloat(key: String, defValue: Float): Float + fun getBoolean(key: String, defValue: Boolean): Boolean + fun contains(key: String): Boolean + fun edit(): Editor + fun registerOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) + fun unregisterOnSharedPreferenceChangeListener(listener: OnSharedPreferenceChangeListener) + + interface Editor { + fun putString(key: String, value: String?): Editor + fun putStringSet(key: String, values: Set?): Editor + fun putInt(key: String, value: Int): Editor + fun putLong(key: String, value: Long): Editor + fun putFloat(key: String, value: Float): Editor + fun putBoolean(key: String, value: Boolean): Editor + fun remove(key: String): Editor + fun clear(): Editor + fun commit(): Boolean + fun apply() + } + + interface OnSharedPreferenceChangeListener { + fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String?) + } +} diff --git a/server/src/main/kotlin/android/content/res/Resources.kt b/server/src/main/kotlin/android/content/res/Resources.kt new file mode 100644 index 000000000..7c7fd7c70 --- /dev/null +++ b/server/src/main/kotlin/android/content/res/Resources.kt @@ -0,0 +1,3 @@ +package android.content.res + +open class Resources diff --git a/server/src/main/kotlin/android/net/Uri.kt b/server/src/main/kotlin/android/net/Uri.kt new file mode 100644 index 000000000..53306d882 --- /dev/null +++ b/server/src/main/kotlin/android/net/Uri.kt @@ -0,0 +1,10 @@ +package android.net + +class Uri private constructor(private val value: String) { + override fun toString(): String = value + + companion object { + @JvmStatic + fun parse(uri: String): Uri = Uri(uri) + } +} diff --git a/server/src/main/kotlin/android/util/Log.kt b/server/src/main/kotlin/android/util/Log.kt new file mode 100644 index 000000000..6cbb4f9df --- /dev/null +++ b/server/src/main/kotlin/android/util/Log.kt @@ -0,0 +1,8 @@ +package android.util + +object Log { + fun d(tag: String, msg: String): Int = println("D/$tag: $msg").let { 0 } + fun i(tag: String, msg: String): Int = println("I/$tag: $msg").let { 0 } + fun w(tag: String, msg: String): Int = println("W/$tag: $msg").let { 0 } + fun e(tag: String, msg: String): Int = println("E/$tag: $msg").let { 0 } +} diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/ConfigStore.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/ConfigStore.kt new file mode 100644 index 000000000..65a3b95f5 --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/ConfigStore.kt @@ -0,0 +1,57 @@ +package com.lagradost.cloudstream3 + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.json.JsonMapper +import com.fasterxml.jackson.module.kotlin.kotlinModule +import com.lagradost.api.Log +import java.nio.file.Files +import java.nio.file.Path + +class ConfigStore(private val configPath: Path) { + private val lock = Any() + private val mapper = JsonMapper.builder() + .addModule(kotlinModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .build() + private val writer = mapper.writerWithDefaultPrettyPrinter() + + fun load(): ServerConfig = synchronized(lock) { + loadUnsafe() + } + + fun save(config: ServerConfig) = synchronized(lock) { + saveUnsafe(config) + } + + fun update(block: (ServerConfig) -> ServerConfig): ServerConfig = synchronized(lock) { + val updated = block(loadUnsafe()) + saveUnsafe(updated) + updated + } + + private fun loadUnsafe(): ServerConfig { + ensureParent() + if (Files.notExists(configPath)) { + val config = ServerConfig() + saveUnsafe(config) + return config + } + return runCatching { + mapper.readValue(configPath.toFile(), ServerConfig::class.java) + }.getOrElse { error -> + Log.e("ServerConfig", "Failed to read config: ${error.message}") + val config = ServerConfig() + saveUnsafe(config) + config + } + } + + private fun saveUnsafe(config: ServerConfig) { + ensureParent() + writer.writeValue(configPath.toFile(), config) + } + + private fun ensureParent() { + configPath.parent?.let { Files.createDirectories(it) } + } +} diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/Models.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/Models.kt new file mode 100644 index 000000000..97439b17d --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/Models.kt @@ -0,0 +1,259 @@ +package com.lagradost.cloudstream3 + +import com.lagradost.cloudstream3.AudioFile +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList +import com.lagradost.cloudstream3.utils.PlayListItem + +data class ServerSettings( + val host: String = "0.0.0.0", + val port: Int = 8080, + val corsAllowedHosts: List = listOf("*"), + val useJsdelivr: Boolean = false, +) + +data class AccountConfig( + val id: String, + val type: String, + val name: String? = null, + val data: Map = emptyMap(), +) + +data class RepositoryData( + val id: String? = null, + val name: String? = null, + val url: String, + val iconUrl: String? = null, + val shortcode: String? = null, + val enabled: Boolean = true, +) + +data class PluginData( + val internalName: String, + val url: String? = null, + val isOnline: Boolean = true, + val filePath: String, + val version: Int, + val repositoryUrl: String? = null, + val name: String? = null, + val status: Int? = null, + val apiVersion: Int? = null, + val authors: List = emptyList(), + val description: String? = null, + val tvTypes: List? = null, + val language: String? = null, + val iconUrl: String? = null, + val fileSize: Long? = null, + val uploadedAt: Long? = null, + val enabled: Boolean = true, +) + +data class ServerConfig( + val server: ServerSettings = ServerSettings(), + val accounts: MutableList = mutableListOf(), + val repositories: MutableList = mutableListOf(), + val plugins: MutableList = mutableListOf(), + val pluginSettings: MutableMap>> = mutableMapOf(), + val providerClasses: MutableList = defaultProviderClasses(), +) + +const val PLUGIN_VERSION_NOT_SET = Int.MIN_VALUE +const val PLUGIN_VERSION_ALWAYS_UPDATE = -1 + +data class Repository( + val iconUrl: String? = null, + val name: String, + val description: String? = null, + val manifestVersion: Int, + val pluginLists: List, +) + +data class SitePlugin( + val url: String, + val status: Int, + val version: Int, + val apiVersion: Int, + val name: String, + val internalName: String, + val authors: List = emptyList(), + val description: String? = null, + val repositoryUrl: String? = null, + val tvTypes: List? = null, + val language: String? = null, + val iconUrl: String? = null, + val fileSize: Long? = null, +) + +data class ExtractorRequest( + val url: String, + val referer: String? = null, +) + +data class LoadLinksRequest( + val data: String, + val isCasting: Boolean = false, +) + +data class ProviderRegisterRequest( + val className: String, +) + +data class AccountUpsertRequest( + val id: String? = null, + val type: String, + val name: String? = null, + val data: Map = emptyMap(), +) + +data class RepositoryAddRequest( + val url: String? = null, + val shortcode: String? = null, + val name: String? = null, + val enabled: Boolean = true, +) + +data class PluginInstallRequest( + val repositoryUrl: String? = null, + val internalName: String? = null, +) + +data class PluginRemoveRequest( + val repositoryUrl: String? = null, + val internalName: String? = null, + val filePath: String? = null, +) + +data class PluginUploadResponse( + val plugin: PluginData, + val path: String, +) + +data class ProviderInfo( + val name: String, + val mainUrl: String, + val lang: String, + val supportedTypes: List, + val hasMainPage: Boolean, + val hasQuickSearch: Boolean, + val sourcePlugin: String? = null, +) + +data class ErrorResponse( + val error: String, +) + +data class RepositoryPluginsResponse( + val repository: RepositoryData, + val plugins: List, +) + +data class SitePluginWithVotes( + val plugin: SitePlugin, + val votes: Int? = null, +) + +data class ExtractorResponse( + val success: Boolean, + val links: List, + val subtitles: List, + val error: String? = null, +) + +data class ExtractorLinkDto( + val source: String, + val name: String, + val url: String, + val referer: String, + val quality: Int, + val type: String, + val headers: Map, + val allHeaders: Map, + val userAgent: String?, + val isM3u8: Boolean, + val isDash: Boolean, + val extractorData: String? = null, + val audioTracks: List = emptyList(), + val playlist: List? = null, +) + +data class PlayListItemDto( + val url: String, + val durationUs: Long, +) + +data class SubtitleDto( + val lang: String, + val url: String, + val headers: Map?, + val langTag: String?, +) + +data class AudioTrackDto( + val url: String, + val headers: Map?, +) + +fun ExtractorLink.toDto(): ExtractorLinkDto { + val playlist = if (this is ExtractorLinkPlayList) { + this.playlist.map { it.toDto() } + } else { + null + } + val allHeaders = getAllHeaders() + val userAgent = headerValue(allHeaders, "User-Agent") ?: USER_AGENT + return ExtractorLinkDto( + source = source, + name = name, + url = url, + referer = referer, + quality = quality, + type = type.name, + headers = headers, + allHeaders = allHeaders, + userAgent = userAgent, + isM3u8 = isM3u8, + isDash = isDash, + extractorData = extractorData, + audioTracks = audioTracks.map { it.toDto() }, + playlist = playlist, + ) +} + +fun SubtitleFile.toDto(): SubtitleDto = SubtitleDto( + lang = lang, + url = url, + headers = headers, + langTag = langTag, +) + +fun MainAPI.toInfo(): ProviderInfo = ProviderInfo( + name = name, + mainUrl = mainUrl, + lang = lang, + supportedTypes = supportedTypes.map { it.name }, + hasMainPage = hasMainPage, + hasQuickSearch = hasQuickSearch, + sourcePlugin = sourcePlugin, +) + +private fun PlayListItem.toDto(): PlayListItemDto = PlayListItemDto( + url = url, + durationUs = durationUs, +) + +private fun AudioFile.toDto(): AudioTrackDto = AudioTrackDto( + url = url, + headers = headers, +) + +private fun headerValue(headers: Map, key: String): String? { + return headers.entries.firstOrNull { it.key.equals(key, ignoreCase = true) }?.value +} + +private fun defaultProviderClasses(): MutableList = mutableListOf( + "com.lagradost.cloudstream3.metaproviders.TmdbProvider", + "com.lagradost.cloudstream3.metaproviders.TraktProvider", +) diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/ProviderRegistry.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/ProviderRegistry.kt new file mode 100644 index 000000000..6798dc811 --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/ProviderRegistry.kt @@ -0,0 +1,60 @@ +package com.lagradost.cloudstream3 + +import com.lagradost.api.Log +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.MainAPI + +class ProviderRegistry { + fun registerFromConfig(config: ServerConfig): List { + val registered = mutableListOf() + for (className in config.providerClasses) { + registerByClassName(className)?.let { registered.add(it) } + } + APIHolder.initAll() + return registered + } + + fun registerByClassName(className: String): MainAPI? { + return runCatching { + if (isClassRegistered(className)) return null + val clazz = Class.forName(className) + if (!MainAPI::class.java.isAssignableFrom(clazz)) { + Log.w("Providers", "Class $className does not extend MainAPI") + return null + } + val instance = clazz.getDeclaredConstructor().newInstance() as MainAPI + addProvider(instance) + instance + }.getOrElse { error -> + Log.e("Providers", "Failed to register $className: ${error.message}") + null + } + } + + fun listProviders(): List = synchronized(APIHolder.allProviders) { + APIHolder.allProviders.toList() + } + + fun removeByName(name: String): Boolean { + val api = APIHolder.getApiFromNameNull(name) ?: return false + APIHolder.removePluginMapping(api) + synchronized(APIHolder.allProviders) { + APIHolder.allProviders.removeIf { it == api } + } + return true + } + + private fun addProvider(api: MainAPI) { + synchronized(APIHolder.allProviders) { + if (APIHolder.allProviders.any { it.name == api.name }) return + APIHolder.allProviders.add(api) + } + APIHolder.addPluginMapping(api) + api.init() + } + + private fun isClassRegistered(className: String): Boolean = + synchronized(APIHolder.allProviders) { + APIHolder.allProviders.any { it::class.qualifiedName == className } + } +} diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/Server.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/Server.kt new file mode 100644 index 000000000..e875ff541 --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/Server.kt @@ -0,0 +1,874 @@ +package com.lagradost.cloudstream3 + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.kotlinModule +import com.lagradost.api.Log +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.MainPageRequest +import com.lagradost.cloudstream3.SubtitleFile +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.sortUrls +import com.lagradost.cloudstream3.plugins.PluginManager +import com.lagradost.cloudstream3.plugins.RepositoryManager +import com.lagradost.cloudstream3.plugins.VotingApi +import com.lagradost.cloudstream3.utils.ExtractorLink +import com.lagradost.cloudstream3.utils.loadExtractor +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod +import io.ktor.http.HttpStatusCode +import io.ktor.http.content.PartData +import io.ktor.http.content.forEachPart +import io.ktor.server.application.call +import io.ktor.server.application.install +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import io.ktor.server.plugins.callloging.CallLogging +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.plugins.cors.routing.CORS +import io.ktor.server.plugins.statuspages.StatusPages +import io.ktor.server.request.receive +import io.ktor.server.request.receiveMultipart +import io.ktor.server.response.respond +import io.ktor.server.response.respondOutputStream +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.put +import io.ktor.server.routing.route +import io.ktor.server.routing.routing +import io.ktor.serialization.jackson.jackson +import io.ktor.utils.io.core.readAvailable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.net.HttpURLConnection +import java.net.URL +import java.nio.file.Files +import java.nio.file.Path +import java.util.UUID +import kotlin.io.DEFAULT_BUFFER_SIZE + +fun main() { + val configPath = resolveConfigPath() + val configStore = ConfigStore(configPath) + val initialConfig = configStore.load() + val providerRegistry = ProviderRegistry() + providerRegistry.registerFromConfig(initialConfig) + RepositoryManager.useJsdelivr = initialConfig.server.useJsdelivr + PluginManager.init(configStore) + + val dataDir = resolveDataDir(configPath) + Files.createDirectories(dataDir) + Files.createDirectories(dataDir.resolve(RepositoryManager.ONLINE_PLUGINS_FOLDER)) + Files.createDirectories(dataDir.resolve(PluginManager.LOCAL_PLUGINS_FOLDER)) + + normalizeRepositories(configStore) + loadPluginsOnStartup(configStore) + + embeddedServer(Netty, host = initialConfig.server.host, port = initialConfig.server.port) { + install(CallLogging) + install(ContentNegotiation) { + jackson { + registerModule(kotlinModule()) + configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + } + } + install(CORS) { + allowMethod(HttpMethod.Get) + allowMethod(HttpMethod.Post) + allowMethod(HttpMethod.Put) + allowMethod(HttpMethod.Delete) + allowMethod(HttpMethod.Patch) + allowHeader(HttpHeaders.ContentType) + allowHeader(HttpHeaders.Authorization) + allowHeader(HttpHeaders.Accept) + allowHeader(HttpHeaders.ContentDisposition) + allowNonSimpleContentTypes = true + val allowedHosts = initialConfig.server.corsAllowedHosts + if (allowedHosts.any { it == "*" }) { + anyHost() + } else { + allowedHosts.forEach { host -> allowHost(host, listOf("http", "https")) } + } + } + install(StatusPages) { + exception { call, cause -> + Log.e("Server", "Unhandled error: ${cause.message}") + call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse(cause.message ?: "Internal error") + ) + } + } + + routing { + get("/health") { + call.respond(mapOf("status" to "ok")) + } + + route("/config") { + get { + call.respond(configStore.load()) + } + put { + val config = call.receive() + configStore.save(config) + RepositoryManager.useJsdelivr = config.server.useJsdelivr + call.respond(config) + } + } + + route("/accounts") { + get { + call.respond(configStore.load().accounts) + } + post { + val request = call.receive() + val id = request.id ?: UUID.randomUUID().toString() + val account = AccountConfig( + id = id, + type = request.type, + name = request.name, + data = request.data + ) + configStore.update { config -> + val index = config.accounts.indexOfFirst { it.id == id } + if (index >= 0) { + config.accounts[index] = account + } else { + config.accounts.add(account) + } + config + } + call.respond(account) + } + put("/{id}") { + val id = call.parameters["id"] + ?: return@put call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing id")) + val request = call.receive() + val account = AccountConfig( + id = id, + type = request.type, + name = request.name, + data = request.data + ) + configStore.update { config -> + val index = config.accounts.indexOfFirst { it.id == id } + if (index >= 0) { + config.accounts[index] = account + } else { + config.accounts.add(account) + } + config + } + call.respond(account) + } + delete("/{id}") { + val id = call.parameters["id"] + ?: return@delete call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing id")) + val removed = configStore.update { config -> + config.accounts.removeIf { it.id == id } + config + } + call.respond(removed.accounts) + } + } + + route("/repositories") { + get { + call.respond(configStore.load().repositories) + } + post { + val request = call.receive() + val input = request.url ?: request.shortcode + ?: return@post call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing url")) + val resolvedUrl = RepositoryManager.parseRepoUrl(input) ?: request.url ?: input + val manifest = RepositoryManager.parseRepository(resolvedUrl) + val repo = RepositoryData( + id = PluginManager.getPluginSanitizedFileName(resolvedUrl), + name = request.name ?: manifest?.name, + url = resolvedUrl, + iconUrl = manifest?.iconUrl, + shortcode = request.shortcode, + enabled = request.enabled + ) + configStore.update { config -> + val index = config.repositories.indexOfFirst { it.url == repo.url || it.id == repo.id } + if (index >= 0) { + config.repositories[index] = repo + } else { + config.repositories.add(repo) + } + config + } + call.respond(repo) + } + delete("/{id}") { + val id = call.parameters["id"] + ?: return@delete call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing id")) + val repo = findRepository(configStore, id) + ?: return@delete call.respond(HttpStatusCode.NotFound, ErrorResponse("Repository not found")) + removeRepositoryPlugins(configStore, dataDir, repo) + configStore.update { config -> + config.repositories.removeIf { it.url == repo.url || it.id == repo.id } + config + } + call.respond(mapOf("removed" to (repo.name ?: repo.url))) + } + get("/{id}/plugins") { + val id = call.parameters["id"] + ?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing id")) + val repo = findRepository(configStore, id) + ?: return@get call.respond(HttpStatusCode.NotFound, ErrorResponse("Repository not found")) + val includeVotes = call.request.queryParameters["votes"]?.toBoolean() == true + val repoPlugins = RepositoryManager.getRepoPlugins(repo.url) + ?: return@get call.respond(HttpStatusCode.NotFound, ErrorResponse("Repository not found")) + val plugins = if (includeVotes) { + repoPlugins.map { (_, plugin) -> + SitePluginWithVotes(plugin, VotingApi.getVotes(plugin.url)) + } + } else { + repoPlugins.map { (_, plugin) -> SitePluginWithVotes(plugin, null) } + } + call.respond(RepositoryPluginsResponse(repo, plugins)) + } + post("/{id}/plugins/install-all") { + val id = call.parameters["id"] + ?: return@post call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing id")) + val repo = findRepository(configStore, id) + ?: return@post call.respond(HttpStatusCode.NotFound, ErrorResponse("Repository not found")) + val installed = installAllPluginsFromRepo(configStore, dataDir, repo) + call.respond(installed) + } + post("/{id}/plugins/{internalName}/install") { + val id = call.parameters["id"] + ?: return@post call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing id")) + val internalName = call.parameters["internalName"] + ?: return@post call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing internalName")) + val repo = findRepository(configStore, id) + ?: return@post call.respond(HttpStatusCode.NotFound, ErrorResponse("Repository not found")) + val plugin = installPluginFromRepo(configStore, dataDir, repo, internalName) + ?: return@post call.respond(HttpStatusCode.NotFound, ErrorResponse("Plugin not found")) + call.respond(plugin) + } + delete("/{id}/plugins") { + val id = call.parameters["id"] + ?: return@delete call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing id")) + val repo = findRepository(configStore, id) + ?: return@delete call.respond(HttpStatusCode.NotFound, ErrorResponse("Repository not found")) + val removed = removeRepositoryPlugins(configStore, dataDir, repo) + call.respond(removed) + } + } + + route("/plugins") { + get { + call.respond(configStore.load().plugins) + } + post("/install") { + val request = call.receive() + val repoInput = request.repositoryUrl + ?: return@post call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing repositoryUrl")) + val repo = findRepository(configStore, repoInput) + ?: return@post call.respond(HttpStatusCode.NotFound, ErrorResponse("Repository not found")) + val internalName = request.internalName + ?: return@post call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing internalName")) + val plugin = installPluginFromRepo(configStore, dataDir, repo, internalName) + ?: return@post call.respond(HttpStatusCode.NotFound, ErrorResponse("Plugin not found")) + call.respond(plugin) + } + post("/local") { + val multipart = call.receiveMultipart() + var storedPlugin: PluginData? = null + var storedPath: String? = null + var error: String? = null + + multipart.forEachPart { part -> + when (part) { + is PartData.FileItem -> { + if (storedPlugin != null || error != null) return@forEachPart + val original = part.originalFileName ?: "plugin.cs3" + val tempFile = Files.createTempFile(dataDir, "plugin-upload-", ".cs3").toFile() + try { + val channel = part.provider() + tempFile.outputStream().use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = channel.readAvailable(buffer, 0, buffer.size) + if (read <= 0) break + output.write(buffer, 0, read) + } + } + val targetDir = PluginManager.getLocalPluginPath(dataDir, original) + if (!PluginManager.extractPluginArchive(tempFile, targetDir)) { + error = "Failed to extract plugin" + return@forEachPart + } + val baseData = PluginManager.toLocalPluginData(targetDir) + val loaded = PluginManager.loadPlugin(targetDir, baseData) + if (loaded == null) { + error = "Failed to load plugin" + return@forEachPart + } + storedPlugin = upsertPluginData(configStore, loaded) + storedPath = targetDir.absolutePath + } finally { + tempFile.delete() + } + } + else -> Unit + } + part.dispose() + } + + val pluginData = storedPlugin + ?: return@post call.respond( + HttpStatusCode.BadRequest, + ErrorResponse(error ?: "Missing file") + ) + call.respond(PluginUploadResponse(pluginData, storedPath ?: pluginData.filePath)) + } + delete { + val request = call.receive() + val removed = removePlugin(configStore, request) + ?: return@delete call.respond(HttpStatusCode.NotFound, ErrorResponse("Plugin not found")) + call.respond(removed) + } + post("/autoupdate") { + val updated = autoUpdatePlugins(configStore, dataDir) + call.respond(updated) + } + } + + route("/extract") { + post { + val request = call.receive() + val result = runExtractor(request.url, request.referer) + call.respond(result) + } + } + + route("/proxy") { + post { + val request = call.receive() + val index = call.request.queryParameters["index"]?.toIntOrNull() + val (links, error) = collectExtractorLinks(request.url, request.referer) + val selected = selectProxyLink(links, index) + if (selected == null) { + val message = error ?: "No extractor links found" + return@post call.respond(HttpStatusCode.NotFound, ErrorResponse(message)) + } + call.response.headers.append(HttpHeaders.AccessControlAllowOrigin, "*") + proxyExtractorLink(call, selected) + } + } + + route("/providers") { + get { + call.respond(providerRegistry.listProviders().map { it.toInfo() }) + } + post("/register") { + val request = call.receive() + val api = providerRegistry.registerByClassName(request.className) + ?: return@post call.respond(HttpStatusCode.BadRequest, ErrorResponse("Failed to register provider")) + configStore.update { config -> + if (!config.providerClasses.contains(request.className)) { + config.providerClasses.add(request.className) + } + config + } + call.respond(api.toInfo()) + } + delete("/{name}") { + val name = call.parameters["name"] + ?: return@delete call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing name")) + val api = APIHolder.getApiFromNameNull(name) + ?: return@delete call.respond(HttpStatusCode.NotFound, ErrorResponse("Provider not found")) + val className = api::class.qualifiedName + if (!providerRegistry.removeByName(name)) { + return@delete call.respond(HttpStatusCode.NotFound, ErrorResponse("Provider not found")) + } + configStore.update { config -> + if (className != null) { + config.providerClasses.remove(className) + } + config + } + call.respond(mapOf("removed" to name)) + } + get("/{name}/main-pages") { + val api = resolveProvider(call.parameters["name"]) + ?: return@get call.respond(HttpStatusCode.NotFound, ErrorResponse("Provider not found")) + call.respond(api.mainPage) + } + get("/{name}/main-page") { + val api = resolveProvider(call.parameters["name"]) + ?: return@get call.respond(HttpStatusCode.NotFound, ErrorResponse("Provider not found")) + val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1 + val name = call.request.queryParameters["name"] + val data = call.request.queryParameters["data"] + val request = buildMainPageRequest(api, name, data) + ?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing main page data")) + val response = runCatching { api.getMainPage(page, request) }.getOrElse { error -> + return@get call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse(error.message ?: "Main page failed") + ) + } ?: return@get call.respond( + HttpStatusCode.NotFound, + ErrorResponse("No main page response") + ) + call.respond(response) + } + get("/{name}/search") { + val api = resolveProvider(call.parameters["name"]) + ?: return@get call.respond(HttpStatusCode.NotFound, ErrorResponse("Provider not found")) + val query = call.request.queryParameters["query"] + ?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing query")) + val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1 + val response = runCatching { api.search(query, page) }.getOrElse { error -> + return@get call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse(error.message ?: "Search failed") + ) + } ?: return@get call.respond(HttpStatusCode.NotFound, ErrorResponse("No results")) + call.respond(response) + } + get("/{name}/quick-search") { + val api = resolveProvider(call.parameters["name"]) + ?: return@get call.respond(HttpStatusCode.NotFound, ErrorResponse("Provider not found")) + val query = call.request.queryParameters["query"] + ?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing query")) + val response = runCatching { api.quickSearch(query) }.getOrElse { error -> + return@get call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse(error.message ?: "Quick search failed") + ) + } ?: return@get call.respond(HttpStatusCode.NotFound, ErrorResponse("No results")) + call.respond(response) + } + get("/{name}/load") { + val api = resolveProvider(call.parameters["name"]) + ?: return@get call.respond(HttpStatusCode.NotFound, ErrorResponse("Provider not found")) + val url = call.request.queryParameters["url"] + ?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing url")) + val response = runCatching { api.load(url) }.getOrElse { error -> + return@get call.respond( + HttpStatusCode.InternalServerError, + ErrorResponse(error.message ?: "Load failed") + ) + } ?: return@get call.respond(HttpStatusCode.NotFound, ErrorResponse("No load response")) + call.respond(response) + } + post("/{name}/links") { + val api = resolveProvider(call.parameters["name"]) + ?: return@post call.respond(HttpStatusCode.NotFound, ErrorResponse("Provider not found")) + val request = call.receive() + val response = runLoadLinks(api, request) + call.respond(response) + } + } + } + }.start(wait = true) +} + +private fun resolveConfigPath(): Path { + val envPath = System.getenv("CLOUDSTREAM_SERVER_CONFIG") + return if (!envPath.isNullOrBlank()) { + Path.of(envPath) + } else { + resolveProjectRoot().resolve("server").resolve("config.json") + } +} + +private fun resolveDataDir(configPath: Path): Path { + val envPath = System.getenv("CLOUDSTREAM_SERVER_DATA_DIR") + if (!envPath.isNullOrBlank()) { + return Path.of(envPath) + } + val parent = configPath.toAbsolutePath().parent + return (parent ?: resolveProjectRoot().resolve("server")).resolve("data") +} + +private fun resolveProjectRoot(): Path { + val cwd = Path.of("").toAbsolutePath().normalize() + return if (cwd.fileName?.toString().equals("server", ignoreCase = true)) { + cwd.parent ?: cwd + } else { + cwd + } +} + +private fun resolveProvider(name: String?): com.lagradost.cloudstream3.MainAPI? { + if (name.isNullOrBlank()) return null + return APIHolder.getApiFromNameNull(name) +} + +private fun normalizeRepositories(configStore: ConfigStore) { + configStore.update { config -> + val normalized = config.repositories.map { repo -> + val id = repo.id ?: PluginManager.getPluginSanitizedFileName(repo.url) + if (repo.id == id) repo else repo.copy(id = id) + } + config.repositories.clear() + config.repositories.addAll(normalized) + config + } +} + +private fun loadPluginsOnStartup(configStore: ConfigStore) { + val config = configStore.load() + val updated = mutableListOf() + for (plugin in config.plugins.filter { it.enabled }) { + val pluginDir = File(plugin.filePath) + if (!pluginDir.exists()) continue + val loaded = PluginManager.loadPlugin(pluginDir, plugin) ?: continue + if (loaded != plugin) updated.add(loaded) + } + if (updated.isNotEmpty()) { + configStore.update { current -> + current.plugins.removeIf { existing -> updated.any { it.filePath == existing.filePath } } + current.plugins.addAll(updated) + current + } + } +} + +private fun findRepository(configStore: ConfigStore, idOrUrl: String): RepositoryData? { + val repos = configStore.load().repositories + return repos.firstOrNull { it.id == idOrUrl || it.url == idOrUrl } +} + +private fun upsertPluginData(configStore: ConfigStore, plugin: PluginData): PluginData { + configStore.update { config -> + config.plugins.removeIf { it.filePath == plugin.filePath } + config.plugins.add(plugin) + config + } + return plugin +} + +private fun findInstalledPluginIfUpToDate( + configStore: ConfigStore, + repositoryUrl: String, + remote: SitePlugin +): PluginData? { + if (remote.version == PLUGIN_VERSION_ALWAYS_UPDATE) return null + val local = configStore.load().plugins.firstOrNull { + it.repositoryUrl == repositoryUrl && + it.internalName.equals(remote.internalName, ignoreCase = true) + } ?: return null + if (local.version != remote.version) return null + if (!File(local.filePath).exists()) return null + return local +} + +private fun installPluginFromArchive( + configStore: ConfigStore, + archive: File, + pluginDir: File, + pluginData: PluginData +): PluginData? { + PluginManager.unloadPlugin(pluginDir.absolutePath) + if (!PluginManager.extractPluginArchive(archive, pluginDir)) return null + val loaded = PluginManager.loadPlugin(pluginDir, pluginData) ?: run { + PluginManager.deletePluginFile(pluginDir) + return null + } + return upsertPluginData(configStore, loaded) +} + +private suspend fun installPluginFromRepo( + configStore: ConfigStore, + dataDir: Path, + repo: RepositoryData, + internalName: String +): PluginData? { + val repoPlugins = RepositoryManager.getRepoPlugins(repo.url) ?: return null + val match = repoPlugins.firstOrNull { (_, plugin) -> + plugin.internalName.equals(internalName, ignoreCase = true) + }?.second ?: return null + findInstalledPluginIfUpToDate(configStore, repo.url, match)?.let { return it } + val pluginDir = PluginManager.getPluginPath(dataDir, match.internalName, repo.url) + val tempFile = Files.createTempFile(dataDir, "plugin-download-", ".cs3").toFile() + return try { + val downloaded = RepositoryManager.downloadPluginToFile(match.url, tempFile) ?: return null + val pluginData = PluginManager.toPluginData(match, repo.url, pluginDir) + installPluginFromArchive(configStore, downloaded, pluginDir, pluginData) + } finally { + tempFile.delete() + } +} + +private suspend fun installAllPluginsFromRepo( + configStore: ConfigStore, + dataDir: Path, + repo: RepositoryData +): List { + val repoPlugins = RepositoryManager.getRepoPlugins(repo.url) ?: return emptyList() + val installed = mutableListOf() + for ((_, plugin) in repoPlugins) { + if (findInstalledPluginIfUpToDate(configStore, repo.url, plugin) != null) continue + val pluginDir = PluginManager.getPluginPath(dataDir, plugin.internalName, repo.url) + val tempFile = Files.createTempFile(dataDir, "plugin-download-", ".cs3").toFile() + try { + val downloaded = RepositoryManager.downloadPluginToFile(plugin.url, tempFile) ?: continue + val pluginData = PluginManager.toPluginData(plugin, repo.url, pluginDir) + installPluginFromArchive(configStore, downloaded, pluginDir, pluginData)?.let { installed.add(it) } + } finally { + tempFile.delete() + } + } + return installed +} + +private fun removeRepositoryPlugins( + configStore: ConfigStore, + dataDir: Path, + repo: RepositoryData +): Map { + val removedFiles = mutableListOf() + val repoFolder = dataDir.resolve(RepositoryManager.ONLINE_PLUGINS_FOLDER) + .resolve(PluginManager.getPluginSanitizedFileName(repo.url)) + .toFile() + val removedPlugins = configStore.load().plugins.filter { + it.repositoryUrl == repo.url || + (repoFolder.absolutePath.isNotBlank() && it.filePath.startsWith(repoFolder.absolutePath)) + } + removedPlugins.forEach { plugin -> + PluginManager.unloadPlugin(plugin.filePath) + if (PluginManager.deletePluginFile(File(plugin.filePath))) { + removedFiles.add(plugin.filePath) + } + } + if (repoFolder.exists()) repoFolder.deleteRecursively() + + configStore.update { config -> + config.plugins.removeIf { + it.repositoryUrl == repo.url || + (repoFolder.absolutePath.isNotBlank() && it.filePath.startsWith(repoFolder.absolutePath)) + } + config + } + return mapOf("removed" to removedFiles.size, "files" to removedFiles) +} + +private fun removePlugin(configStore: ConfigStore, request: PluginRemoveRequest): PluginData? { + val config = configStore.load() + val plugin = when { + !request.filePath.isNullOrBlank() -> + config.plugins.firstOrNull { it.filePath == request.filePath } + !request.repositoryUrl.isNullOrBlank() && !request.internalName.isNullOrBlank() -> + config.plugins.firstOrNull { + it.repositoryUrl == request.repositoryUrl && + it.internalName.equals(request.internalName, ignoreCase = true) + } + !request.internalName.isNullOrBlank() -> + config.plugins.firstOrNull { it.internalName.equals(request.internalName, ignoreCase = true) } + else -> null + } ?: return null + + PluginManager.unloadPlugin(plugin.filePath) + PluginManager.deletePluginFile(File(plugin.filePath)) + configStore.update { update -> + update.plugins.removeIf { it.filePath == plugin.filePath } + update + } + return plugin +} + +private suspend fun autoUpdatePlugins(configStore: ConfigStore, dataDir: Path): List { + val config = configStore.load() + val repoList = config.repositories.filter { it.enabled } + val repoPlugins = mutableMapOf>() + for (repo in repoList) { + val plugins = RepositoryManager.getRepoPlugins(repo.url)?.map { it.second } ?: emptyList() + repoPlugins[repo.url] = plugins + } + + val updated = mutableListOf() + for (plugin in config.plugins.filter { it.isOnline }) { + val repositoryUrl = plugin.repositoryUrl ?: continue + val remotePlugins = repoPlugins[repositoryUrl] ?: continue + val remote = remotePlugins.firstOrNull { + it.internalName.equals(plugin.internalName, ignoreCase = true) + } ?: continue + if (PluginManager.isDisabled(remote)) continue + if (!PluginManager.shouldUpdate(plugin, remote)) continue + val pluginDir = PluginManager.getPluginPath(dataDir, remote.internalName, repositoryUrl) + val tempFile = Files.createTempFile(dataDir, "plugin-download-", ".cs3").toFile() + try { + val downloaded = RepositoryManager.downloadPluginToFile(remote.url, tempFile) ?: continue + val pluginData = PluginManager.toPluginData(remote, repositoryUrl, pluginDir) + installPluginFromArchive(configStore, downloaded, pluginDir, pluginData)?.let { updated.add(it) } + } finally { + tempFile.delete() + } + } + return updated +} + +private suspend fun runExtractor(url: String, referer: String?): ExtractorResponse { + return withContext(Dispatchers.IO) { + val links = mutableListOf() + val subtitles = mutableListOf() + val result = runCatching { + loadExtractor( + url = url, + referer = referer, + subtitleCallback = { subtitles.add(it) }, + callback = { links.add(it) } + ) + } + if (result.isFailure) { + val error = result.exceptionOrNull() + return@withContext ExtractorResponse( + success = false, + links = links.map { it.toDto() }, + subtitles = subtitles.map { it.toDto() }, + error = error?.message ?: "Extractor failed", + ) + } + ExtractorResponse( + success = result.getOrNull() == true, + links = links.map { it.toDto() }, + subtitles = subtitles.map { it.toDto() }, + ) + } +} + +private suspend fun collectExtractorLinks( + url: String, + referer: String? +): Pair, String?> { + return withContext(Dispatchers.IO) { + val links = mutableListOf() + val result = runCatching { + loadExtractor( + url = url, + referer = referer, + subtitleCallback = {}, + callback = { links.add(it) } + ) + } + val error = result.exceptionOrNull()?.message + links to error + } +} + +private fun selectProxyLink(links: List, index: Int?): ExtractorLink? { + if (links.isEmpty()) return null + if (index != null) return links.getOrNull(index) + return sortUrls(links.toSet()).firstOrNull() +} + +private suspend fun proxyExtractorLink(call: io.ktor.server.application.ApplicationCall, link: ExtractorLink) { + val requestHeaders = buildProxyHeaders(link, call.request.headers) + val connection = withContext(Dispatchers.IO) { + (URL(link.url).openConnection() as HttpURLConnection).apply { + instanceFollowRedirects = true + requestMethod = "GET" + requestHeaders.forEach { (key, value) -> + setRequestProperty(key, value) + } + connect() + } + } + val statusCode = connection.responseCode + call.response.status(HttpStatusCode.fromValue(statusCode)) + connection.contentType?.let { call.response.headers.append(HttpHeaders.ContentType, it) } + if (connection.contentLengthLong >= 0) { + call.response.headers.append(HttpHeaders.ContentLength, connection.contentLengthLong.toString()) + } + connection.getHeaderField("Accept-Ranges")?.let { + call.response.headers.append(HttpHeaders.AcceptRanges, it) + } + connection.getHeaderField("Content-Range")?.let { + call.response.headers.append(HttpHeaders.ContentRange, it) + } + connection.getHeaderField("Content-Disposition")?.let { + call.response.headers.append(HttpHeaders.ContentDisposition, it) + } + connection.getHeaderField("Cache-Control")?.let { + call.response.headers.append(HttpHeaders.CacheControl, it) + } + + call.respondOutputStream { + withContext(Dispatchers.IO) { + val input = if (statusCode >= 400) { + connection.errorStream ?: connection.inputStream + } else { + connection.inputStream + } + input.use { stream -> + stream.copyTo(this@respondOutputStream) + } + } + } + connection.disconnect() +} + +private fun buildProxyHeaders( + link: ExtractorLink, + requestHeaders: io.ktor.http.Headers +): Map { + var headers = link.getAllHeaders() + if (headers.keys.none { it.equals("User-Agent", ignoreCase = true) }) { + headers = headers + mapOf("User-Agent" to USER_AGENT) + } + val range = requestHeaders[HttpHeaders.Range] + if (range != null && headers.keys.none { it.equals(HttpHeaders.Range, ignoreCase = true) }) { + headers = headers + mapOf(HttpHeaders.Range to range) + } + return headers +} + +private suspend fun runLoadLinks(api: com.lagradost.cloudstream3.MainAPI, request: LoadLinksRequest): ExtractorResponse { + return withContext(Dispatchers.IO) { + val links = mutableListOf() + val subtitles = mutableListOf() + val result = runCatching { + api.loadLinks( + data = request.data, + isCasting = request.isCasting, + subtitleCallback = { subtitles.add(it) }, + callback = { links.add(it) } + ) + } + if (result.isFailure) { + val error = result.exceptionOrNull() + return@withContext ExtractorResponse( + success = false, + links = links.map { it.toDto() }, + subtitles = subtitles.map { it.toDto() }, + error = error?.message ?: "Load links failed", + ) + } + ExtractorResponse( + success = result.getOrNull() == true, + links = links.map { it.toDto() }, + subtitles = subtitles.map { it.toDto() }, + ) + } +} + +private fun buildMainPageRequest( + api: com.lagradost.cloudstream3.MainAPI, + name: String?, + data: String? +): MainPageRequest? { + if (!data.isNullOrBlank()) { + return MainPageRequest( + name = name ?: data, + data = data, + horizontalImages = false + ) + } + if (name.isNullOrBlank()) return null + val entry = api.mainPage.firstOrNull { it.name == name } ?: return null + return MainPageRequest( + name = entry.name, + data = entry.data, + horizontalImages = entry.horizontalImages + ) +} diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/ServerContext.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/ServerContext.kt new file mode 100644 index 000000000..3b3564e5e --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/ServerContext.kt @@ -0,0 +1,173 @@ +package com.lagradost.cloudstream3 + +import android.content.Context +import android.content.SharedPreferences +import java.io.File + +class ServerContext( + private val configStore: ConfigStore, + private val pluginKey: String, + private val filesDir: File, +) : Context() { + override fun getSharedPreferences(name: String, mode: Int): SharedPreferences { + return ConfigSharedPreferences(configStore, pluginKey, name) + } + + override fun getApplicationContext(): Context = this + + override fun getFilesDir(): File = filesDir +} + +private class ConfigSharedPreferences( + private val configStore: ConfigStore, + private val pluginKey: String, + private val prefsName: String, +) : SharedPreferences { + override fun getAll(): Map = readPrefs() + + override fun getString(key: String, defValue: String?): String? = + readPrefs()[key] as? String ?: defValue + + override fun getStringSet(key: String, defValues: Set?): Set? { + val value = readPrefs()[key] ?: return defValues + return when (value) { + is Set<*> -> value.filterIsInstance().toSet() + is Collection<*> -> value.filterIsInstance().toSet() + else -> defValues + } + } + + override fun getInt(key: String, defValue: Int): Int = + coerceInt(readPrefs()[key], defValue) + + override fun getLong(key: String, defValue: Long): Long = + coerceLong(readPrefs()[key], defValue) + + override fun getFloat(key: String, defValue: Float): Float = + coerceFloat(readPrefs()[key], defValue) + + override fun getBoolean(key: String, defValue: Boolean): Boolean = + coerceBoolean(readPrefs()[key], defValue) + + override fun contains(key: String): Boolean = readPrefs().containsKey(key) + + override fun edit(): SharedPreferences.Editor = Editor(configStore, pluginKey, prefsName) + + override fun registerOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener + ) { + } + + override fun unregisterOnSharedPreferenceChangeListener( + listener: SharedPreferences.OnSharedPreferenceChangeListener + ) { + } + + private fun readPrefs(): Map { + val config = configStore.load() + val pluginSettings = config.pluginSettings[pluginKey] ?: return emptyMap() + val prefs = pluginSettings[prefsName] ?: return emptyMap() + return prefs.toMap() + } + + private fun coerceInt(value: Any?, defValue: Int): Int = when (value) { + is Int -> value + is Number -> value.toInt() + is String -> value.toIntOrNull() ?: defValue + else -> defValue + } + + private fun coerceLong(value: Any?, defValue: Long): Long = when (value) { + is Long -> value + is Number -> value.toLong() + is String -> value.toLongOrNull() ?: defValue + else -> defValue + } + + private fun coerceFloat(value: Any?, defValue: Float): Float = when (value) { + is Float -> value + is Number -> value.toFloat() + is String -> value.toFloatOrNull() ?: defValue + else -> defValue + } + + private fun coerceBoolean(value: Any?, defValue: Boolean): Boolean = when (value) { + is Boolean -> value + is String -> value.equals("true", ignoreCase = true) || + (value == "1") + is Number -> value.toInt() != 0 + else -> defValue + } + + private class Editor( + private val configStore: ConfigStore, + private val pluginKey: String, + private val prefsName: String, + ) : SharedPreferences.Editor { + private val pending = mutableMapOf() + private val removals = mutableSetOf() + private var clear = false + + override fun putString(key: String, value: String?): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun putStringSet(key: String, values: Set?): SharedPreferences.Editor { + pending[key] = values + return this + } + + override fun putInt(key: String, value: Int): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun putLong(key: String, value: Long): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun putFloat(key: String, value: Float): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor { + pending[key] = value + return this + } + + override fun remove(key: String): SharedPreferences.Editor { + removals.add(key) + return this + } + + override fun clear(): SharedPreferences.Editor { + clear = true + return this + } + + override fun commit(): Boolean { + apply() + return true + } + + override fun apply() { + configStore.update { config -> + val pluginSettings = config.pluginSettings.getOrPut(pluginKey) { mutableMapOf() } + val prefs = pluginSettings.getOrPut(prefsName) { mutableMapOf() } + if (clear) prefs.clear() + removals.forEach { prefs.remove(it) } + pending.forEach { (key, value) -> + if (value == null) { + prefs.remove(key) + } else { + prefs[key] = value + } + } + config + } + } + } +} diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/actions/VideoClickAction.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/actions/VideoClickAction.kt new file mode 100644 index 000000000..bec0dd1c6 --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/actions/VideoClickAction.kt @@ -0,0 +1,11 @@ +package com.lagradost.cloudstream3.actions + +abstract class VideoClickAction( + val name: String +) { + var sourcePlugin: String? = null +} + +object VideoClickActionHolder { + val allVideoClickActions: MutableList = mutableListOf() +} diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt new file mode 100644 index 000000000..9b51a0174 --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/plugins/Plugin.kt @@ -0,0 +1,24 @@ +package com.lagradost.cloudstream3.plugins + +import android.content.Context +import android.content.res.Resources +import android.util.Log +import com.lagradost.cloudstream3.actions.VideoClickAction +import com.lagradost.cloudstream3.actions.VideoClickActionHolder + +abstract class Plugin : BasePlugin() { + open fun load(context: Context) { + load() + } + + fun registerVideoClickAction(element: VideoClickAction) { + Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction") + element.sourcePlugin = this.filename + synchronized(VideoClickActionHolder.allVideoClickActions) { + VideoClickActionHolder.allVideoClickActions.add(element) + } + } + + var resources: Resources? = null + var openSettings: ((context: Context) -> Unit)? = null +} diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/plugins/PluginData.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/plugins/PluginData.kt new file mode 100644 index 000000000..f1a2a3322 --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/plugins/PluginData.kt @@ -0,0 +1,9 @@ +package com.lagradost.cloudstream3.plugins + +data class PluginData( + val internalName: String, + val url: String? = null, + val isOnline: Boolean = true, + val filePath: String, + val version: Int, +) diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/plugins/PluginManager.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/plugins/PluginManager.kt new file mode 100644 index 000000000..7c88b09f2 --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/plugins/PluginManager.kt @@ -0,0 +1,265 @@ +package com.lagradost.cloudstream3.plugins + +import com.lagradost.api.Log +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.ConfigStore +import com.lagradost.cloudstream3.PLUGIN_VERSION_ALWAYS_UPDATE +import com.lagradost.cloudstream3.PLUGIN_VERSION_NOT_SET +import com.lagradost.cloudstream3.PluginData +import com.lagradost.cloudstream3.ServerContext +import com.lagradost.cloudstream3.SitePlugin +import com.lagradost.cloudstream3.utils.extractorApis +import com.googlecode.d2j.dex.Dex2jar +import java.io.File +import java.io.FileInputStream +import java.net.URLClassLoader +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption +import java.util.zip.ZipInputStream + +object PluginManager { + const val LOCAL_PLUGINS_FOLDER = "plugins" + private const val MANIFEST_NAME = "manifest.json" + private const val JAR_NAME = "plugin.jar" + private val stubContext = android.content.Context() + private var configStore: ConfigStore? = null + + private data class LoadedPlugin( + val classLoader: URLClassLoader, + val instance: BasePlugin + ) + + private val plugins = LinkedHashMap() + + fun init(configStore: ConfigStore) { + this.configStore = configStore + } + + fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { + val reservedChars = "|\\?*<\":>+[]/\'" + var tempName = name + for (c in reservedChars) { + tempName = tempName.replace(c, ' ') + } + if (removeSpaces) tempName = tempName.replace(" ", "") + return tempName.replace(" ", " ").trim(' ') + } + + fun getPluginSanitizedFileName(name: String): String { + return sanitizeFilename(name, true) + "." + name.hashCode() + } + + fun getPluginPath(baseDir: Path, internalName: String, repositoryUrl: String): File { + val folderName = getPluginSanitizedFileName(repositoryUrl) + val fileName = getPluginSanitizedFileName(internalName) + return baseDir.resolve(RepositoryManager.ONLINE_PLUGINS_FOLDER) + .resolve(folderName) + .resolve(fileName) + .toFile() + } + + fun getLocalPluginPath(baseDir: Path, fileName: String): File { + val baseName = File(fileName).nameWithoutExtension + val safeName = sanitizeFilename(baseName, false) + return baseDir.resolve(LOCAL_PLUGINS_FOLDER).resolve(safeName).toFile() + } + + fun toPluginData( + sitePlugin: SitePlugin, + repositoryUrl: String, + file: File + ): PluginData { + return PluginData( + internalName = sitePlugin.internalName, + url = sitePlugin.url, + isOnline = true, + filePath = file.absolutePath, + version = sitePlugin.version, + repositoryUrl = repositoryUrl, + name = sitePlugin.name, + status = sitePlugin.status, + apiVersion = sitePlugin.apiVersion, + authors = sitePlugin.authors, + description = sitePlugin.description, + tvTypes = sitePlugin.tvTypes, + language = sitePlugin.language, + iconUrl = sitePlugin.iconUrl, + fileSize = sitePlugin.fileSize, + ) + } + + fun toLocalPluginData(file: File, internalName: String? = null): PluginData { + val name = internalName ?: file.nameWithoutExtension + return PluginData( + internalName = name, + url = null, + isOnline = false, + filePath = file.absolutePath, + version = PLUGIN_VERSION_NOT_SET, + repositoryUrl = null, + name = name, + fileSize = file.length(), + uploadedAt = System.currentTimeMillis() + ) + } + + fun shouldUpdate(local: PluginData, remote: SitePlugin): Boolean { + if (remote.version == PLUGIN_VERSION_ALWAYS_UPDATE) return true + return remote.version > local.version + } + + fun isDisabled(remote: SitePlugin): Boolean = remote.status == 0 + + fun validOnlineData(baseDir: Path, local: PluginData, repositoryUrl: String): Boolean { + return getPluginPath(baseDir, local.internalName, repositoryUrl).absolutePath == local.filePath + } + + fun extractPluginArchive(archive: File, targetDir: File): Boolean { + return runCatching { + if (targetDir.exists()) targetDir.deleteRecursively() + Files.createDirectories(targetDir.toPath()) + ZipInputStream(FileInputStream(archive)).use { zip -> + var entry = zip.nextEntry + while (entry != null) { + val resolved = targetDir.toPath().resolve(entry.name).normalize() + if (!resolved.startsWith(targetDir.toPath())) { + Log.e("PluginManager", "Blocked zip entry: ${entry.name}") + return@runCatching false + } + if (entry.isDirectory) { + Files.createDirectories(resolved) + } else { + resolved.parent?.let { Files.createDirectories(it) } + Files.copy(zip, resolved, StandardCopyOption.REPLACE_EXISTING) + } + zip.closeEntry() + entry = zip.nextEntry + } + } + true + }.getOrElse { + Log.e("PluginManager", "Failed to extract plugin: ${it.message}") + false + } + } + + fun readManifest(pluginDir: File): BasePlugin.Manifest? { + val manifestFile = findByName(pluginDir, MANIFEST_NAME) ?: return null + return runCatching { + com.lagradost.cloudstream3.mapper.readValue( + manifestFile, + BasePlugin.Manifest::class.java + ) + }.getOrElse { + Log.e("PluginManager", "Failed to parse manifest: ${it.message}") + null + } + } + + fun loadPlugin(pluginDir: File, data: PluginData): PluginData? { + val manifest = readManifest(pluginDir) ?: return null + val className = manifest.pluginClassName + if (className.isNullOrBlank()) { + Log.e("PluginManager", "Manifest missing pluginClassName for ${pluginDir.name}") + return null + } + val jarFile = ensureJar(pluginDir) ?: return null + Log.i("PluginManager", "Loading plugin ${pluginDir.name} with class $className") + unloadPlugin(pluginDir.absolutePath) + val loader = URLClassLoader(arrayOf(jarFile.toURI().toURL()), PluginManager::class.java.classLoader) + return try { + val clazz = loader.loadClass(className) + val instance = clazz.getDeclaredConstructor().newInstance() as? BasePlugin + ?: throw IllegalStateException("Class $className is not a BasePlugin") + instance.filename = pluginDir.absolutePath + val updated = applyManifest(data, manifest) + plugins[pluginDir.absolutePath] = LoadedPlugin(loader, instance) + if (instance is Plugin) { + val context = configStore?.let { ServerContext(it, data.internalName, pluginDir) } ?: stubContext + instance.load(context) + } else { + instance.load() + } + APIHolder.initAll() + updated + } catch (e: Throwable) { + Log.e( + "PluginManager", + "Failed to load plugin ${pluginDir.name}: ${e.message}\n${e.stackTraceToString()}" + ) + runCatching { loader.close() } + null + } + } + + fun unloadPlugin(absolutePath: String) { + val loaded = plugins.remove(absolutePath) ?: return + val instance = loaded.instance + runCatching { instance.beforeUnload() }.onFailure { + Log.e("PluginManager", "Failed to unload plugin: ${it.message}") + } + + synchronized(APIHolder.apis) { + APIHolder.apis.filter { it.sourcePlugin == instance.filename }.forEach { + APIHolder.removePluginMapping(it) + } + } + synchronized(APIHolder.allProviders) { + APIHolder.allProviders.removeIf { it.sourcePlugin == instance.filename } + } + extractorApis.removeIf { it.sourcePlugin == instance.filename } + runCatching { loaded.classLoader.close() } + } + + private fun applyManifest(data: PluginData, manifest: BasePlugin.Manifest): PluginData { + val version = manifest.version ?: data.version + val name = manifest.name ?: data.name + return data.copy(version = version, name = name) + } + + private fun ensureJar(pluginDir: File): File? { + val dexFile = findDexFile(pluginDir) ?: run { + Log.e("PluginManager", "No dex file found in ${pluginDir.absolutePath}") + return null + } + val jarFile = File(pluginDir, JAR_NAME) + if (jarFile.exists() && jarFile.lastModified() >= dexFile.lastModified()) return jarFile + return try { + Log.i("PluginManager", "Converting dex to jar for ${pluginDir.name}") + Dex2jar.from(dexFile) + .skipDebug(true) + .dontSanitizeNames(true) + .topoLogicalSort() + .to(jarFile.toPath()) + jarFile.setLastModified(dexFile.lastModified()) + jarFile + } catch (e: Throwable) { + Log.e( + "PluginManager", + "Failed to build jar for ${pluginDir.name}: ${e.message}\n${e.stackTraceToString()}" + ) + null + } + } + + private fun findByName(root: File, name: String): File? { + val direct = File(root, name) + if (direct.exists()) return direct + return root.walkTopDown().firstOrNull { it.isFile && it.name.equals(name, ignoreCase = true) } + } + + private fun findDexFile(root: File): File? { + return root.walkTopDown().firstOrNull { it.isFile && it.extension.equals("dex", ignoreCase = true) } + } + + fun deletePluginFile(file: File): Boolean { + return runCatching { + if (file.exists()) file.deleteRecursively() + true + }.getOrElse { + Log.e("PluginManager", "Failed to delete plugin file: ${it.message}") + false + } + } +} diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/plugins/RepositoryManager.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/plugins/RepositoryManager.kt new file mode 100644 index 000000000..56a106567 --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/plugins/RepositoryManager.kt @@ -0,0 +1,88 @@ +package com.lagradost.cloudstream3.plugins + +import com.lagradost.api.Log +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.Repository +import com.lagradost.cloudstream3.SitePlugin +import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson +import java.io.BufferedInputStream +import java.io.File +import java.io.InputStream +import java.io.OutputStream + +object RepositoryManager { + const val ONLINE_PLUGINS_FOLDER = "Extensions" + private val GH_REGEX = + Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") + var useJsdelivr: Boolean = false + + fun convertRawGitUrl(url: String): String { + if (!useJsdelivr) return url + val match = GH_REGEX.find(url) ?: return url + val (user, repo, rest) = match.destructured + return "https://cdn.jsdelivr.net/gh/$user/$repo@$rest" + } + + suspend fun parseRepoUrl(url: String): String? { + val fixedUrl = url.trim() + return if (fixedUrl.contains("^https?://".toRegex())) { + fixedUrl + } else if (fixedUrl.contains("^(cloudstreamrepo://)|(https://cs\\.repo/\\??)".toRegex())) { + fixedUrl.replace("^(cloudstreamrepo://)|(https://cs\\.repo/\\??)".toRegex(), "").let { + if (!it.contains("^https?://".toRegex())) "https://${it}" else fixedUrl + } + } else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) { + runCatching { + app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false) + .headers["Location"]?.let { target -> + if (target.startsWith("https://cutt.ly/404")) null + else if (target.removeSuffix("/") == "https://cutt.ly") null + else target + } + }.getOrNull() + } else { + null + } + } + + suspend fun parseRepository(url: String): Repository? { + return runCatching { app.get(convertRawGitUrl(url)).parsedSafe() }.getOrNull() + } + + private suspend fun parsePlugins(pluginUrls: String): List { + return try { + val response = app.get(convertRawGitUrl(pluginUrls)) + tryParseJson>(response.text)?.toList() ?: emptyList() + } catch (t: Throwable) { + Log.e("RepositoryManager", "Failed to parse plugins: ${t.message}") + emptyList() + } + } + + suspend fun getRepoPlugins(repositoryUrl: String): List>? { + val repo = parseRepository(repositoryUrl) ?: return null + return repo.pluginLists.flatMap { url -> + parsePlugins(url).map { repositoryUrl to it } + } + } + + suspend fun downloadPluginToFile(pluginUrl: String, file: File): File? { + return runCatching { + file.parentFile?.mkdirs() + if (file.exists()) file.delete() + file.createNewFile() + val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body + write(body.byteStream(), file.outputStream()) + file + }.getOrNull() + } + + private fun write(stream: InputStream, output: OutputStream) { + val input = BufferedInputStream(stream) + val dataBuffer = ByteArray(512) + var readBytes: Int + while (input.read(dataBuffer).also { readBytes = it } != -1) { + output.write(dataBuffer, 0, readBytes) + } + } +} diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/plugins/VotingApi.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/plugins/VotingApi.kt new file mode 100644 index 000000000..3c89deef9 --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/plugins/VotingApi.kt @@ -0,0 +1,31 @@ +package com.lagradost.cloudstream3.plugins + +import com.lagradost.cloudstream3.app +import java.security.MessageDigest + +object VotingApi { + private const val API_DOMAIN = "https://counterapi.com/api" + + private fun transformUrl(url: String): String = + MessageDigest.getInstance("SHA-256") + .digest("${url}#funny-salt".toByteArray()) + .fold("") { str, it -> str + "%02x".format(it) } + + private fun getRepository(pluginUrl: String) = pluginUrl + .split("/") + .drop(2) + .take(3) + .joinToString("-") + + suspend fun getVotes(pluginUrl: String): Int { + val url = + "${API_DOMAIN}/cs-${getRepository(pluginUrl)}/vote/${transformUrl(pluginUrl)}?readOnly=true" + return runCatching { + app.get(url).parsedSafe()?.value ?: 0 + }.getOrDefault(0) + } + + private data class Result( + val value: Int? + ) +} diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/AccountManager.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/AccountManager.kt new file mode 100644 index 000000000..a59cfe3c2 --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/AccountManager.kt @@ -0,0 +1,67 @@ +package com.lagradost.cloudstream3.syncproviders + +abstract class AccountManager { + companion object { + const val NONE_ID: Int = -1 + const val ACCOUNT_TOKEN = "auth_tokens" + const val ACCOUNT_IDS = "auth_ids" + + const val APP_STRING = "cloudstreamapp" + const val APP_STRING_REPO = "cloudstreamrepo" + const val APP_STRING_PLAYER = "cloudstreamplayer" + const val APP_STRING_SEARCH = "cloudstreamsearch" + const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching" + const val APP_STRING_SHARE = "csshare" + + val malApi = com.lagradost.cloudstream3.syncproviders.providers.MALApi() + val aniListApi = com.lagradost.cloudstream3.syncproviders.providers.AniListApi() + val simklApi = com.lagradost.cloudstream3.syncproviders.providers.SimklApi() + val localListApi = com.lagradost.cloudstream3.syncproviders.providers.LocalList() + + val openSubtitlesApi = com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi() + val addic7ed = com.lagradost.cloudstream3.syncproviders.providers.Addic7ed() + val subDlApi = com.lagradost.cloudstream3.syncproviders.providers.SubDlApi() + val subSourceApi = com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi() + + var cachedAccounts: MutableMap> = mutableMapOf() + var cachedAccountIds: MutableMap = mutableMapOf() + + val syncApis: Array = arrayOf( + SyncRepo(malApi), + SyncRepo(aniListApi), + SyncRepo(simklApi), + SyncRepo(localListApi), + ) + val subtitleProviders: Array = arrayOf( + SubtitleRepo(openSubtitlesApi), + SubtitleRepo(addic7ed), + SubtitleRepo(subDlApi), + SubtitleRepo(subSourceApi), + ) + val allApis: Array = arrayOf( + *syncApis, + *subtitleProviders, + ) + + fun accounts(prefix: String): Array { + return cachedAccounts[prefix] ?: emptyArray() + } + + fun updateAccounts(prefix: String, array: Array) { + cachedAccounts[prefix] = array + } + + fun updateAccountsId(prefix: String, id: Int) { + cachedAccountIds[prefix] = id + } + + fun initMainAPI() { + } + + fun secondsToReadable(seconds: Int, completedValue: String): String { + if (seconds <= 0) return completedValue + val minutes = seconds / 60 + return "${minutes}m" + } + } +} diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt new file mode 100644 index 000000000..de850e8ce --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt @@ -0,0 +1,31 @@ +package com.lagradost.cloudstream3.syncproviders + +abstract class AuthAPI { + open val name: String = "Unknown" + open val idPrefix: String = "NONE" + open val icon: String? = null + open val requiresLogin: Boolean = false + open val createAccountUrl: String? = null + open val hasOAuth2: Boolean = false + open val hasPin: Boolean = false + open val hasInApp: Boolean = false + open val inAppLoginRequirement: AuthLoginRequirement? = null + + open fun isValidRedirectUrl(url: String): Boolean = false + + open fun loginRequest(): AuthLoginPage? = null + + open suspend fun login(form: AuthLoginResponse): AuthToken? = null + + open suspend fun login(payload: AuthPinData): AuthToken? = null + + open suspend fun login(redirectUrl: String, payload: String?): AuthToken? = null + + open suspend fun refreshToken(token: AuthToken): AuthToken? = null + + open suspend fun user(token: AuthToken): AuthUser? = null + + open suspend fun pinRequest(): AuthPinData? = null + + open suspend fun invalidateToken(token: AuthToken) {} +} diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/AuthModels.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/AuthModels.kt new file mode 100644 index 000000000..110f53897 --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/AuthModels.kt @@ -0,0 +1,53 @@ +package com.lagradost.cloudstream3.syncproviders + +data class AuthLoginPage( + val url: String, + val payload: String? = null, +) + +data class AuthToken( + val accessToken: String? = null, + val refreshToken: String? = null, + val accessTokenLifetime: Long? = null, + val refreshTokenLifetime: Long? = null, + val payload: String? = null, +) { + fun isAccessTokenExpired(marginSec: Long = 10L): Boolean { + val lifetime = accessTokenLifetime ?: return false + return (System.currentTimeMillis() / 1000) + marginSec >= lifetime + } +} + +data class AuthUser( + val name: String? = null, + val id: Int = 0, + val profilePicture: String? = null, + val profilePictureHeaders: Map? = null, +) + +data class AuthData( + val user: AuthUser, + val token: AuthToken, +) + +data class AuthPinData( + val deviceCode: String = "", + val userCode: String = "", + val verificationUrl: String = "", + val expiresIn: Int = 0, + val interval: Int = 0, +) + +data class AuthLoginRequirement( + val password: Boolean = false, + val username: Boolean = false, + val email: Boolean = false, + val server: Boolean = false, +) + +data class AuthLoginResponse( + val username: String? = null, + val password: String? = null, + val email: String? = null, + val server: String? = null, +) diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt new file mode 100644 index 000000000..1e08c014d --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt @@ -0,0 +1,30 @@ +package com.lagradost.cloudstream3.syncproviders + +open class AuthRepo(open val api: AuthAPI) { + val idPrefix: String + get() = api.idPrefix + val name: String + get() = api.name + val icon: String? + get() = api.icon + val requiresLogin: Boolean + get() = api.requiresLogin + val createAccountUrl: String? + get() = api.createAccountUrl + val hasOAuth2: Boolean + get() = api.hasOAuth2 + val hasPin: Boolean + get() = api.hasPin + val hasInApp: Boolean + get() = api.hasInApp + val inAppLoginRequirement: AuthLoginRequirement? + get() = api.inAppLoginRequirement + + open suspend fun freshAuth(): AuthData? = authData() + + open fun authData(): AuthData? = null + + open fun authToken(): AuthToken? = authData()?.token + + open fun authUser(): AuthUser? = authData()?.user +} diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt new file mode 100644 index 000000000..329ec91dd --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt @@ -0,0 +1,3 @@ +package com.lagradost.cloudstream3.syncproviders + +abstract class SubtitleAPI : AuthAPI() diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt new file mode 100644 index 000000000..1ab92a2c3 --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt @@ -0,0 +1,3 @@ +package com.lagradost.cloudstream3.syncproviders + +class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt new file mode 100644 index 000000000..4eaf27f2c --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt @@ -0,0 +1,34 @@ +package com.lagradost.cloudstream3.syncproviders + +open class SyncAPI : AuthAPI() { + open var requireLibraryRefresh: Boolean = false + open val mainUrl: String = "NONE" + open val syncIdName: SyncIdName? = null + + open suspend fun updateStatus( + auth: AuthData?, + id: String, + newStatus: AbstractSyncStatus + ): Boolean = false + + open suspend fun status(auth: AuthData?, id: String): AbstractSyncStatus? = null + + open suspend fun load(auth: AuthData?, id: String): SyncResult? = null + + open suspend fun library(auth: AuthData?): LibraryMetadata? = null + + open class AbstractSyncStatus + + class SyncResult + + class LibraryMetadata +} + +enum class SyncIdName { + Anilist, + MyAnimeList, + Trakt, + Imdb, + Simkl, + LocalList, +} diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt new file mode 100644 index 000000000..a617fae65 --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt @@ -0,0 +1,26 @@ +package com.lagradost.cloudstream3.syncproviders + +class SyncRepo(override val api: SyncAPI) : AuthRepo(api) { + val syncIdName: SyncIdName? = api.syncIdName + var requireLibraryRefresh: Boolean + get() = api.requireLibraryRefresh + set(value) { + api.requireLibraryRefresh = value + } + + suspend fun updateStatus(id: String, newStatus: SyncAPI.AbstractSyncStatus): Result = + runCatching { + val status = api.updateStatus(freshAuth(), id, newStatus) + requireLibraryRefresh = true + status + } + + suspend fun status(id: String): Result = + runCatching { api.status(freshAuth(), id) } + + suspend fun load(id: String): Result = + runCatching { api.load(freshAuth(), id) } + + suspend fun library(): Result = + runCatching { api.library(freshAuth()) } +} diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/providers/Providers.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/providers/Providers.kt new file mode 100644 index 000000000..4bb78dc5d --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/syncproviders/providers/Providers.kt @@ -0,0 +1,49 @@ +package com.lagradost.cloudstream3.syncproviders.providers + +import com.lagradost.cloudstream3.syncproviders.SubtitleAPI +import com.lagradost.cloudstream3.syncproviders.SyncAPI +import com.lagradost.cloudstream3.syncproviders.SyncIdName + +class AniListApi : SyncAPI() { + override val name: String = "AniList" + override val idPrefix: String = "ANILIST" + override val syncIdName: SyncIdName = SyncIdName.Anilist +} + +class MALApi : SyncAPI() { + override val name: String = "MyAnimeList" + override val idPrefix: String = "MAL" + override val syncIdName: SyncIdName = SyncIdName.MyAnimeList +} + +class SimklApi : SyncAPI() { + override val name: String = "Simkl" + override val idPrefix: String = "SIMKL" + override val syncIdName: SyncIdName = SyncIdName.Simkl +} + +class LocalList : SyncAPI() { + override val name: String = "LocalList" + override val idPrefix: String = "LOCAL" + override val syncIdName: SyncIdName = SyncIdName.LocalList +} + +class OpenSubtitlesApi : SubtitleAPI() { + override val name: String = "OpenSubtitles" + override val idPrefix: String = "OPENSUBTITLES" +} + +class Addic7ed : SubtitleAPI() { + override val name: String = "Addic7ed" + override val idPrefix: String = "ADDIC7ED" +} + +class SubDlApi : SubtitleAPI() { + override val name: String = "SubDl" + override val idPrefix: String = "SUBDL" +} + +class SubSourceApi : SubtitleAPI() { + override val name: String = "SubSource" + override val idPrefix: String = "SUBSOURCE" +} diff --git a/server/ui/.gitignore b/server/ui/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/server/ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/server/ui/README.md b/server/ui/README.md new file mode 100644 index 000000000..e6cd94fce --- /dev/null +++ b/server/ui/README.md @@ -0,0 +1,47 @@ +# Svelte + TS + Vite + +This template should help get you started developing with Svelte and TypeScript in Vite. + +## Recommended IDE Setup + +[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). + +## Need an official Svelte framework? + +Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more. + +## Technical considerations + +**Why use this over SvelteKit?** + +- It brings its own routing solution which might not be preferable for some users. +- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app. + +This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project. + +Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate. + +**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?** + +Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information. + +**Why include `.vscode/extensions.json`?** + +Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project. + +**Why enable `allowJs` in the TS template?** + +While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant. + +**Why is HMR not preserving my local component state?** + +HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr). + +If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR. + +```ts +// store.ts +// An extremely simple external store +import { writable } from 'svelte/store' +export default writable(0) +``` diff --git a/server/ui/index.html b/server/ui/index.html new file mode 100644 index 000000000..0b17b679b --- /dev/null +++ b/server/ui/index.html @@ -0,0 +1,13 @@ + + + + + + + ui + + +
+ + + diff --git a/server/ui/package-lock.json b/server/ui/package-lock.json new file mode 100644 index 000000000..a426b35c5 --- /dev/null +++ b/server/ui/package-lock.json @@ -0,0 +1,2029 @@ +{ + "name": "ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui", + "version": "0.0.0", + "dependencies": { + "@tailwindcss/vite": "^4.1.18", + "daisyui": "^5.5.14", + "svelte-spa-router": "^4.0.1", + "tailwindcss": "^4.1.18" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tsconfig/svelte": "^5.0.6", + "@types/node": "^24.10.1", + "svelte": "^5.43.8", + "svelte-check": "^4.3.4", + "typescript": "~5.9.3", + "vite": "^7.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", + "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", + "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", + "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", + "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", + "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", + "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", + "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", + "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", + "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", + "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", + "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", + "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", + "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", + "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", + "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", + "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", + "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", + "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz", + "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", + "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", + "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", + "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", + "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", + "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", + "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.6.tgz", + "integrity": "sha512-yGxYL0I9eETH1/DR9qVJey4DAsCdeau4a9wYPKuXfEhm8lFO8wg+LLYJjIpAm6Fw7HSlhepPhYPDop75485yWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", + "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/daisyui": { + "version": "5.5.14", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.14.tgz", + "integrity": "sha512-L47rvw7I7hK68TA97VB8Ee0woHew+/ohR6Lx6Ah/krfISOqcG4My7poNpX5Mo5/ytMxiR40fEaz6njzDi7cuSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", + "integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.18.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", + "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz", + "integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regexparam": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-2.0.2.tgz", + "integrity": "sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", + "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.56.0", + "@rollup/rollup-android-arm64": "4.56.0", + "@rollup/rollup-darwin-arm64": "4.56.0", + "@rollup/rollup-darwin-x64": "4.56.0", + "@rollup/rollup-freebsd-arm64": "4.56.0", + "@rollup/rollup-freebsd-x64": "4.56.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", + "@rollup/rollup-linux-arm-musleabihf": "4.56.0", + "@rollup/rollup-linux-arm64-gnu": "4.56.0", + "@rollup/rollup-linux-arm64-musl": "4.56.0", + "@rollup/rollup-linux-loong64-gnu": "4.56.0", + "@rollup/rollup-linux-loong64-musl": "4.56.0", + "@rollup/rollup-linux-ppc64-gnu": "4.56.0", + "@rollup/rollup-linux-ppc64-musl": "4.56.0", + "@rollup/rollup-linux-riscv64-gnu": "4.56.0", + "@rollup/rollup-linux-riscv64-musl": "4.56.0", + "@rollup/rollup-linux-s390x-gnu": "4.56.0", + "@rollup/rollup-linux-x64-gnu": "4.56.0", + "@rollup/rollup-linux-x64-musl": "4.56.0", + "@rollup/rollup-openbsd-x64": "4.56.0", + "@rollup/rollup-openharmony-arm64": "4.56.0", + "@rollup/rollup-win32-arm64-msvc": "4.56.0", + "@rollup/rollup-win32-ia32-msvc": "4.56.0", + "@rollup/rollup-win32-x64-gnu": "4.56.0", + "@rollup/rollup-win32-x64-msvc": "4.56.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.48.0.tgz", + "integrity": "sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.2", + "esm-env": "^1.2.1", + "esrap": "^2.2.1", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.5.tgz", + "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte-spa-router": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svelte-spa-router/-/svelte-spa-router-4.0.1.tgz", + "integrity": "sha512-2JkmUQ2f9jRluijL58LtdQBIpynSbem2eBGp4zXdi7aDY1znbR6yjw0KsonD0aq2QLwf4Yx4tBJQjxIjgjXHKg==", + "license": "MIT", + "dependencies": { + "regexparam": "2.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ItalyPaleAle" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/server/ui/package.json b/server/ui/package.json new file mode 100644 index 000000000..4703c5098 --- /dev/null +++ b/server/ui/package.json @@ -0,0 +1,27 @@ +{ + "name": "ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@tsconfig/svelte": "^5.0.6", + "@types/node": "^24.10.1", + "svelte": "^5.43.8", + "svelte-check": "^4.3.4", + "typescript": "~5.9.3", + "vite": "^7.2.4" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.18", + "daisyui": "^5.5.14", + "svelte-spa-router": "^4.0.1", + "tailwindcss": "^4.1.18" + } +} diff --git a/server/ui/src/App.svelte b/server/ui/src/App.svelte new file mode 100644 index 000000000..435a89d6f --- /dev/null +++ b/server/ui/src/App.svelte @@ -0,0 +1,43 @@ + + +
+ + + + + +
+ +
+
+ + diff --git a/server/ui/src/api/index.ts b/server/ui/src/api/index.ts new file mode 100644 index 000000000..596769a3c --- /dev/null +++ b/server/ui/src/api/index.ts @@ -0,0 +1,114 @@ +export class CloudstreamAPI { + private baseUrl: string; + + constructor(baseUrl: string = '/api') { + this.baseUrl = baseUrl; + } + + private async request(endpoint: string, options: RequestInit = {}): Promise { + const response = await fetch(`${this.baseUrl}${endpoint}`, options); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`API Error: ${response.status} ${response.statusText} - ${errorText}`); + } + return response.json(); + } + + // --- Config --- + async getConfig(): Promise { + return this.request('/config'); + } + + async updateConfig(config: any): Promise { + return this.request('/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + } + + // --- Repositories --- + async getRepositories(): Promise { + return this.request('/repositories'); + } + + async addRepository(urlOrShortcode: string, name?: string): Promise { + const body: any = {}; + if (urlOrShortcode.startsWith('http')) { + body.url = urlOrShortcode; + } else { + body.shortcode = urlOrShortcode; + } + if (name) body.name = name; + + return this.request('/repositories', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } + + async removeRepository(id: string): Promise { + return this.request(`/repositories/${id}`, { method: 'DELETE' }); + } + + async getRepositoryPlugins(id: string): Promise { + return this.request(`/repositories/${id}/plugins`); + } + + async installRepositoryPlugin(repoId: string, internalName: string): Promise { + return this.request(`/repositories/${repoId}/plugins/${internalName}/install`, { + method: 'POST' + }); + } + + // --- Plugins --- + async getPlugins(): Promise { + return this.request('/plugins'); + } + + async installPlugin(repositoryUrl: string, internalName: string): Promise { + return this.request('/plugins/install', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ repositoryUrl, internalName }), + }); + } + + async removePlugin(internalName: string): Promise { + return this.request('/plugins', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ internalName }), + }); + } + + async uploadPlugin(file: File): Promise { + const formData = new FormData(); + formData.append('file', file); + return this.request('/plugins/local', { + method: 'POST', + body: formData, + }); + } + + // --- Providers --- + async getProviders(): Promise { + return this.request('/providers'); + } + + async getProviderMainPage(providerName: string): Promise { + return this.request(`/providers/${providerName}/main-page`); + } + + async searchProvider(providerName: string, query: string): Promise { + // Basic search; robust implementation would handle page pagination + return this.request(`/providers/${providerName}/search?query=${encodeURIComponent(query)}`); + } + + async loadMedia(providerName: string, url: string): Promise { + return this.request(`/providers/${providerName}/load?url=${encodeURIComponent(url)}`); + } +} + +export const api = new CloudstreamAPI(); diff --git a/server/ui/src/app.css b/server/ui/src/app.css new file mode 100644 index 000000000..9e7ebfa12 --- /dev/null +++ b/server/ui/src/app.css @@ -0,0 +1,34 @@ +@import "tailwindcss"; + +@import "tailwindcss"; + +@plugin "daisyui" { + themes: forest, dracula, dim, sunset, autumn, synthwave, pastel, nord, coffee, night, lemonade; +} + +@theme { + --font-display: "Work Sans", "Inter", sans-serif; + --font-body: "Inter", sans-serif; +} + +@layer base { + + body { + @apply bg-base-300 text-base-content font-sans antialiased; + font-feature-settings: "cv11", "ss01"; + } +} + +/* Scrollbar Styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + @apply bg-transparent; +} + +::-webkit-scrollbar-thumb { + @apply bg-base-content/20 rounded-full hover:bg-base-content/40 transition-colors; +} \ No newline at end of file diff --git a/server/ui/src/assets/svelte.svg b/server/ui/src/assets/svelte.svg new file mode 100644 index 000000000..c5e08481f --- /dev/null +++ b/server/ui/src/assets/svelte.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server/ui/src/components/layout/Sidebar.svelte b/server/ui/src/components/layout/Sidebar.svelte new file mode 100644 index 000000000..33f7a1b16 --- /dev/null +++ b/server/ui/src/components/layout/Sidebar.svelte @@ -0,0 +1,54 @@ + + + diff --git a/server/ui/src/components/layout/ToastContainer.svelte b/server/ui/src/components/layout/ToastContainer.svelte new file mode 100644 index 000000000..1e1db8b5b --- /dev/null +++ b/server/ui/src/components/layout/ToastContainer.svelte @@ -0,0 +1,23 @@ + + +
+ {#each $toast as t (t.id)} +
toast.remove(t.id)} + role="alert" + > + {t.message} +
+ {/each} +
diff --git a/server/ui/src/components/shared/ConfirmModal.svelte b/server/ui/src/components/shared/ConfirmModal.svelte new file mode 100644 index 000000000..7b32ab625 --- /dev/null +++ b/server/ui/src/components/shared/ConfirmModal.svelte @@ -0,0 +1,44 @@ + + + + + + diff --git a/server/ui/src/components/shared/PosterCard.svelte b/server/ui/src/components/shared/PosterCard.svelte new file mode 100644 index 000000000..e5ef9ca7e --- /dev/null +++ b/server/ui/src/components/shared/PosterCard.svelte @@ -0,0 +1,36 @@ + + +
+
+ {title} + +
+ +
+
+
+

{title}

+ {#if subtitle} +

{subtitle}

+ {/if} +
+
diff --git a/server/ui/src/main.ts b/server/ui/src/main.ts new file mode 100644 index 000000000..664a057af --- /dev/null +++ b/server/ui/src/main.ts @@ -0,0 +1,9 @@ +import { mount } from 'svelte' +import './app.css' +import App from './App.svelte' + +const app = mount(App, { + target: document.getElementById('app')!, +}) + +export default app diff --git a/server/ui/src/pages/Home.svelte b/server/ui/src/pages/Home.svelte new file mode 100644 index 000000000..c063ac89e --- /dev/null +++ b/server/ui/src/pages/Home.svelte @@ -0,0 +1,143 @@ + + +
+ + +
+

Browse

+ +
+ + {#if loading} +
+ +
+ {:else if error} +
+
⚠️
+

Failed to load content

+

{error}

+ +
+ {:else if mainPageData} + + + {#if mainPageData.length > 0 && mainPageData[0].list.length > 0} + {@const heroItem = mainPageData[0].list[0]} +
+
+ Hero +
+
+ +
+
Featured
+

+ {heroItem.name} +

+

+ {heroItem.type} • Click to watch now +

+
+ + +
+
+
+ {/if} + + +
+ {#each mainPageData as row} + {#if row.list && row.list.length > 0} +
+

+
+ {row.name} +

+ + +
+
+ {#each row.list as item} +
+ +
+ {/each} +
+
+
+ {/if} + {/each} +
+ + {:else} +
+ Select a provider to start browsing. +
+ {/if} +
+ + diff --git a/server/ui/src/pages/PluginManager.svelte b/server/ui/src/pages/PluginManager.svelte new file mode 100644 index 000000000..5985f9d6c --- /dev/null +++ b/server/ui/src/pages/PluginManager.svelte @@ -0,0 +1,495 @@ + + +
+
+ {#if browsingRepo} +
+ +
+

{browsingRepo.name}

+

Browsing Repository

+
+
+ {:else} +

Plugins

+ {/if} + + {#if !browsingRepo} +
+ + +
+ {/if} +
+ + {#if browsingRepo} + +
+ + + + + +
+ + +
+ {#each filteredRepoPlugins as plugin} + {@const status = getPluginStatus(plugin, $plugins)} +
+
+
+
+ {#if plugin.iconUrl} + e.currentTarget.style.display = 'none'} + /> + {:else} +
+ + +
+ {/if} +
+

{plugin.name}

+

by {plugin.authors?.join(', ') || 'Unknown'}

+
+ {#if plugin.language} + {plugin.language} + {/if} + {#if plugin.version} + v{plugin.version} + {/if} +
+
+
+
+ + {#if plugin.tvTypes && plugin.tvTypes.length > 0} +
+ {#each plugin.tvTypes.slice(0, 3) as type} + {type} + {/each} + {#if plugin.tvTypes.length > 3} + +{plugin.tvTypes.length - 3} + {/if} +
+ {/if} + +

+ {plugin.description || 'No description provided.'} +

+ +
+ {#if status === 'update'} + + {:else if status === 'installed'} + + {:else} + + {/if} +
+
+
+ {/each} + {#if filteredRepoPlugins.length === 0} +
+ {#if repoPluginsList.length === 0} + No plugins found in this repository. + {:else} + No plugins match your filters. + {/if} +
+ {/if} +
+ + {:else if activeTab === 'installed'} +
+ +
+
+

Install Local Plugin

+

Drag & drop .cs3 file or click to upload

+ +
+
+ + +
+ {#each $plugins as plugin} +
+
+
+
+ {#if plugin.iconUrl} + + {:else} +
+ {plugin.name?.charAt(0) || '?'} +
+ {/if} +
+

{plugin.name || plugin.internalName}

+

v{plugin.version} • {plugin.authors?.join(', ')}

+
+
+
+ {plugin.status === 1 ? 'Working' : 'Issues'} +
+
+ +

+ {plugin.description || 'No description provided.'} +

+ +
+ +
+
+
+ {/each} + {#if $plugins.length === 0} +
+ No plugins installed. Check Repositories to add some. +
+ {/if} +
+
+ {:else if activeTab === 'repositories'} +
+ +
+
+ + +
+
+ + +
+ +
+ + +
+ {#each $repositories as repo} +
+
+ {#if repo.iconUrl} + + {:else} + + {/if} +
+

{repo.name}

+
{repo.url}
+
+
+
+ + +
+
+ {/each} +
+
+ {/if} + + + + +
diff --git a/server/ui/src/pages/Search.svelte b/server/ui/src/pages/Search.svelte new file mode 100644 index 000000000..50e500fc2 --- /dev/null +++ b/server/ui/src/pages/Search.svelte @@ -0,0 +1,127 @@ + + +
+ +
+

Search

+ + +
+
+ +
+ e.key === 'Enter' && handleSearch()} + /> + {#if loading} + + {/if} +
+ +
+
+ + +
+ {#each tvTypes as type} + + {/each} +
+
+ + +
+ {#if error} +
+ + {error} +
+ {:else if searchResults.length === 0 && !loading && query} +
+ No results found for "{query}". +
+ {:else if searchResults.length > 0} +
+ {#each searchResults as item} + + {/each} +
+ {/if} +
+ +
diff --git a/server/ui/src/pages/Settings.svelte b/server/ui/src/pages/Settings.svelte new file mode 100644 index 000000000..2629c19aa --- /dev/null +++ b/server/ui/src/pages/Settings.svelte @@ -0,0 +1,152 @@ + + +
+

Settings

+ + + + +
+ + {#if activeTab === 'general'} +
+

Server Configuration

+ +
+
+ Host +
+ +
+ +
+
+ Port +
+ +
+ +
+ +
+ +
+ + {#if saveStatus === 'success'} + Saved! + {/if} + {#if saveStatus === 'error'} + Failed to save. + {/if} +
+
+ + {:else if activeTab === 'theme'} +
+

Appearance

+
+ {#each themes as t} + + {/each} +
+
+ + {:else if activeTab === 'accounts'} +
+

Accounts

+
+ + Account management coming soon. Configuration is stored in `config.json`. +
+
+ + + + + + + + + + {#if $configStore && $configStore.accounts} + {#each $configStore.accounts as account} + + + + + + {/each} + {/if} + +
TypeNameID
{account.type}{account.name || '-'}{account.id}
+ {#if !$configStore?.accounts?.length} +

No accounts configured.

+ {/if} +
+
+ {/if} + +
+
diff --git a/server/ui/src/stores/index.ts b/server/ui/src/stores/index.ts new file mode 100644 index 000000000..851258ab8 --- /dev/null +++ b/server/ui/src/stores/index.ts @@ -0,0 +1,32 @@ +import { writable } from 'svelte/store'; +import { api } from '../api'; + +export const config = writable(null); +export const providers = writable([]); +export const plugins = writable([]); +export const repositories = writable([]); + +export const activeProvider = writable(null); + +export async function loadInitialData() { + try { + const [cfg, provs, plugs, repos] = await Promise.all([ + api.getConfig(), + api.getProviders(), + api.getPlugins(), + api.getRepositories() + ]); + + config.set(cfg); + providers.set(provs); + plugins.set(plugs); + repositories.set(repos); + + if (provs.length > 0) { + // Set first provider as active if none selected (logic could be improved) + activeProvider.update(current => current || provs[0].name); + } + } catch (err) { + console.error("Failed to load initial data", err); + } +} diff --git a/server/ui/src/stores/theme.ts b/server/ui/src/stores/theme.ts new file mode 100644 index 000000000..c6fa15851 --- /dev/null +++ b/server/ui/src/stores/theme.ts @@ -0,0 +1,42 @@ +import { writable } from 'svelte/store'; + +const THEME_KEY = 'cloudstream_theme'; +const DEFAULT_THEME = 'forest'; + +export const themes = [ + 'forest', + 'dracula', + 'dim', + 'sunset', + 'autumn', + 'synthwave', + 'pastel', + 'nord', + 'coffee', + 'night', + 'lemonade' +]; + +function createThemeStore() { + const stored = localStorage.getItem(THEME_KEY); + const initial = stored && themes.includes(stored) ? stored : DEFAULT_THEME; + + const { subscribe, set } = writable(initial); + + return { + subscribe, + set: (theme: string) => { + if (!themes.includes(theme)) return; + localStorage.setItem(THEME_KEY, theme); + document.documentElement.setAttribute('data-theme', theme); + set(theme); + }, + init: () => { + const current = localStorage.getItem(THEME_KEY) || DEFAULT_THEME; + document.documentElement.setAttribute('data-theme', current); + set(current); + } + }; +} + +export const theme = createThemeStore(); diff --git a/server/ui/src/stores/toast.ts b/server/ui/src/stores/toast.ts new file mode 100644 index 000000000..c03d1a012 --- /dev/null +++ b/server/ui/src/stores/toast.ts @@ -0,0 +1,38 @@ +import { writable } from 'svelte/store'; + +export type ToastType = 'info' | 'success' | 'warning' | 'error'; + +export interface Toast { + id: number; + message: string; + type: ToastType; +} + +function createToastStore() { + const { subscribe, update } = writable([]); + + let nextId = 0; + + return { + subscribe, + push: (message: string, type: ToastType = 'info', duration = 3000) => { + const id = nextId++; + update(toasts => [...toasts, { id, message, type }]); + + if (duration > 0) { + setTimeout(() => { + update(toasts => toasts.filter(t => t.id !== id)); + }, duration); + } + }, + remove: (id: number) => { + update(toasts => toasts.filter(t => t.id !== id)); + }, + success: (msg: string, duration?: number) => toast.push(msg, 'success', duration), + error: (msg: string, duration?: number) => toast.push(msg, 'error', duration), + info: (msg: string, duration?: number) => toast.push(msg, 'info', duration), + warning: (msg: string, duration?: number) => toast.push(msg, 'warning', duration) + }; +} + +export const toast = createToastStore(); diff --git a/server/ui/svelte.config.js b/server/ui/svelte.config.js new file mode 100644 index 000000000..96b345548 --- /dev/null +++ b/server/ui/svelte.config.js @@ -0,0 +1,8 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */ +export default { + // Consult https://svelte.dev/docs#compile-time-svelte-preprocess + // for more information about preprocessors + preprocess: vitePreprocess(), +} diff --git a/server/ui/tsconfig.app.json b/server/ui/tsconfig.app.json new file mode 100644 index 000000000..31c18cfd8 --- /dev/null +++ b/server/ui/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "types": ["svelte", "vite/client"], + "noEmit": true, + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable checkJs if you'd like to use dynamic types in JS. + * Note that setting allowJs false does not prevent the use + * of JS in `.svelte` files. + */ + "allowJs": true, + "checkJs": true, + "moduleDetection": "force" + }, + "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] +} diff --git a/server/ui/tsconfig.json b/server/ui/tsconfig.json new file mode 100644 index 000000000..1ffef600d --- /dev/null +++ b/server/ui/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/server/ui/tsconfig.node.json b/server/ui/tsconfig.node.json new file mode 100644 index 000000000..8a67f62f4 --- /dev/null +++ b/server/ui/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/server/ui/vite.config.ts b/server/ui/vite.config.ts new file mode 100644 index 000000000..13ce8cbfd --- /dev/null +++ b/server/ui/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' +import tailwindcss from '@tailwindcss/vite'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + svelte(), + tailwindcss() + ], + server: { + proxy: { + '/api': { + target: 'http://127.0.0.1:8080', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + }, + }, + }, +}) diff --git a/settings.gradle.kts b/settings.gradle.kts index 73bf5a195..947bd10ff 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,4 +18,4 @@ dependencyResolutionManagement { } rootProject.name = "CloudStream" -include(":app", ":library", ":docs") +include(":app", ":library", ":docs", ":server") From 890b30c47d441a135f74b3eb84830879f91ee2d0 Mon Sep 17 00:00:00 2001 From: Cloudburst <18114966+C10udburst@users.noreply.github.com> Date: Thu, 22 Jan 2026 19:59:49 +0100 Subject: [PATCH 2/2] large server change enhance Home page with provider selection and improved data loading create Play page for media playback enhance PluginManager with provider overrides improve Search page with provider selection persist active provider in local storage update theme options --- .../com/lagradost/cloudstream3/Models.kt | 22 + .../cloudstream3/ProviderRegistry.kt | 12 +- .../com/lagradost/cloudstream3/Server.kt | 497 +++++++++++++++--- .../lagradost/cloudstream3/utils/DataStore.kt | 206 ++++++++ server/ui/package-lock.json | 57 +- server/ui/package.json | 3 +- server/ui/src/App.svelte | 4 + server/ui/src/api/index.ts | 54 +- server/ui/src/app.css | 26 +- server/ui/src/assets/poster-fallback.svg | 8 + server/ui/src/assets/svelte.svg | 1 - .../ui/src/components/layout/Sidebar.svelte | 37 +- .../src/components/shared/PosterCard.svelte | 20 +- .../components/shared/ProviderPicker.svelte | 128 +++++ server/ui/src/main.ts | 4 + server/ui/src/pages/Details.svelte | 374 +++++++++++++ server/ui/src/pages/Home.svelte | 190 ++++--- server/ui/src/pages/Play.svelte | 219 ++++++++ server/ui/src/pages/PluginManager.svelte | 273 ++++++++-- server/ui/src/pages/Search.svelte | 37 +- server/ui/src/stores/index.ts | 20 +- server/ui/src/stores/theme.ts | 7 +- server/ui/vite.config.ts | 3 + 23 files changed, 2004 insertions(+), 198 deletions(-) create mode 100644 server/src/main/kotlin/com/lagradost/cloudstream3/utils/DataStore.kt create mode 100644 server/ui/src/assets/poster-fallback.svg delete mode 100644 server/ui/src/assets/svelte.svg create mode 100644 server/ui/src/components/shared/ProviderPicker.svelte create mode 100644 server/ui/src/pages/Details.svelte create mode 100644 server/ui/src/pages/Play.svelte diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/Models.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/Models.kt index 97439b17d..1bd7285e3 100644 --- a/server/src/main/kotlin/com/lagradost/cloudstream3/Models.kt +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/Models.kt @@ -27,6 +27,7 @@ data class RepositoryData( val name: String? = null, val url: String, val iconUrl: String? = null, + val description: String? = null, val shortcode: String? = null, val enabled: Boolean = true, ) @@ -58,6 +59,7 @@ data class ServerConfig( val plugins: MutableList = mutableListOf(), val pluginSettings: MutableMap>> = mutableMapOf(), val providerClasses: MutableList = defaultProviderClasses(), + val providerOverrides: MutableList = mutableListOf(), ) const val PLUGIN_VERSION_NOT_SET = Int.MIN_VALUE @@ -90,6 +92,8 @@ data class SitePlugin( data class ExtractorRequest( val url: String, val referer: String? = null, + val headers: Map? = null, + val userAgent: String? = null, ) data class LoadLinksRequest( @@ -101,6 +105,20 @@ data class ProviderRegisterRequest( val className: String, ) +data class ProviderOverride( + val parentClassName: String, + val name: String, + val url: String, + val lang: String, +) + +data class ProviderOverrideRequest( + val parentClassName: String? = null, + val name: String? = null, + val url: String? = null, + val lang: String? = null, +) + data class AccountUpsertRequest( val id: String? = null, val type: String, @@ -138,6 +156,8 @@ data class ProviderInfo( val supportedTypes: List, val hasMainPage: Boolean, val hasQuickSearch: Boolean, + val className: String, + val canBeOverridden: Boolean, val sourcePlugin: String? = null, ) @@ -236,6 +256,8 @@ fun MainAPI.toInfo(): ProviderInfo = ProviderInfo( supportedTypes = supportedTypes.map { it.name }, hasMainPage = hasMainPage, hasQuickSearch = hasQuickSearch, + className = this::class.qualifiedName ?: this::class.java.name, + canBeOverridden = canBeOverridden, sourcePlugin = sourcePlugin, ) diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/ProviderRegistry.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/ProviderRegistry.kt index 6798dc811..cebe00cc6 100644 --- a/server/src/main/kotlin/com/lagradost/cloudstream3/ProviderRegistry.kt +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/ProviderRegistry.kt @@ -23,8 +23,7 @@ class ProviderRegistry { return null } val instance = clazz.getDeclaredConstructor().newInstance() as MainAPI - addProvider(instance) - instance + if (addProvider(instance)) instance else null }.getOrElse { error -> Log.e("Providers", "Failed to register $className: ${error.message}") null @@ -44,13 +43,18 @@ class ProviderRegistry { return true } - private fun addProvider(api: MainAPI) { + fun registerCustomProvider(api: MainAPI): Boolean { + return addProvider(api) + } + + private fun addProvider(api: MainAPI): Boolean { synchronized(APIHolder.allProviders) { - if (APIHolder.allProviders.any { it.name == api.name }) return + if (APIHolder.allProviders.any { it.name == api.name }) return false APIHolder.allProviders.add(api) } APIHolder.addPluginMapping(api) api.init() + return true } private fun isClassRegistered(className: String): Boolean = diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/Server.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/Server.kt index e875ff541..c0db64cec 100644 --- a/server/src/main/kotlin/com/lagradost/cloudstream3/Server.kt +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/Server.kt @@ -2,6 +2,7 @@ package com.lagradost.cloudstream3 import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.module.kotlin.kotlinModule +import com.fasterxml.jackson.module.kotlin.readValue import com.lagradost.api.Log import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.MainPageRequest @@ -30,8 +31,10 @@ import io.ktor.server.request.receive import io.ktor.server.request.receiveMultipart import io.ktor.server.response.respond import io.ktor.server.response.respondOutputStream +import io.ktor.server.response.respondText import io.ktor.server.routing.delete import io.ktor.server.routing.get +import io.ktor.server.routing.head import io.ktor.server.routing.post import io.ktor.server.routing.put import io.ktor.server.routing.route @@ -43,9 +46,12 @@ import kotlinx.coroutines.withContext import java.io.File import java.net.HttpURLConnection import java.net.URL +import java.net.URI +import java.net.URLEncoder import java.nio.file.Files import java.nio.file.Path import java.util.UUID +import java.util.Base64 import kotlin.io.DEFAULT_BUFFER_SIZE fun main() { @@ -64,6 +70,8 @@ fun main() { normalizeRepositories(configStore) loadPluginsOnStartup(configStore) + cleanupTempPluginArchives(dataDir) + applyProviderOverrides(configStore, providerRegistry) embeddedServer(Netty, host = initialConfig.server.host, port = initialConfig.server.port) { install(CallLogging) @@ -189,6 +197,7 @@ fun main() { name = request.name ?: manifest?.name, url = resolvedUrl, iconUrl = manifest?.iconUrl, + description = manifest?.description, shortcode = request.shortcode, enabled = request.enabled ) @@ -349,16 +358,46 @@ fun main() { } route("/proxy") { + get { + val url = call.request.queryParameters["url"] + ?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing url")) + val referer = call.request.queryParameters["referer"] + val userAgent = call.request.queryParameters["userAgent"] + val headersEncoded = call.request.queryParameters["headers"] + val headers = decodeHeadersParam(headersEncoded) + val requestHeaders = buildDirectHeaders(call.request.headers, referer, headers, userAgent) + proxyUrl(call, url, requestHeaders, HttpMethod.Get) + } + head { + val url = call.request.queryParameters["url"] + ?: return@head call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing url")) + val referer = call.request.queryParameters["referer"] + val userAgent = call.request.queryParameters["userAgent"] + val headersEncoded = call.request.queryParameters["headers"] + val headers = decodeHeadersParam(headersEncoded) + val requestHeaders = buildDirectHeaders(call.request.headers, referer, headers, userAgent) + proxyUrl(call, url, requestHeaders, HttpMethod.Head) + } post { val request = call.receive() val index = call.request.queryParameters["index"]?.toIntOrNull() + val direct = call.request.queryParameters["direct"]?.toBoolean() == true + if (direct) { + val requestHeaders = buildDirectHeaders( + call.request.headers, + request.referer, + request.headers ?: emptyMap(), + request.userAgent + ) + proxyUrl(call, request.url, requestHeaders, HttpMethod.Get) + return@post + } val (links, error) = collectExtractorLinks(request.url, request.referer) val selected = selectProxyLink(links, index) if (selected == null) { val message = error ?: "No extractor links found" return@post call.respond(HttpStatusCode.NotFound, ErrorResponse(message)) } - call.response.headers.append(HttpHeaders.AccessControlAllowOrigin, "*") proxyExtractorLink(call, selected) } } @@ -367,6 +406,84 @@ fun main() { get { call.respond(providerRegistry.listProviders().map { it.toInfo() }) } + route("/overrides") { + get { + call.respond(configStore.load().providerOverrides) + } + post { + val request = call.receive() + val parentClassName = request.parentClassName?.trim() + val name = request.name?.trim() + val url = request.url?.trim() + if (parentClassName.isNullOrBlank() || name.isNullOrBlank() || url.isNullOrBlank()) { + return@post call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("Missing override fields") + ) + } + + val base = findBaseProviderByClassName(parentClassName) + ?: return@post call.respond( + HttpStatusCode.NotFound, + ErrorResponse("Base provider not found") + ) + if (!base.canBeOverridden) { + return@post call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("Base provider cannot be overridden") + ) + } + + val resolvedLang = request.lang?.trim().takeUnless { it.isNullOrBlank() } ?: base.lang + val overrideEntry = ProviderOverride( + parentClassName = base::class.java.name, + name = name, + url = url.trimEnd('/'), + lang = resolvedLang + ) + + val config = configStore.load() + if (config.providerOverrides.any { it.name.equals(name, ignoreCase = true) }) { + return@post call.respond( + HttpStatusCode.Conflict, + ErrorResponse("Override name already exists") + ) + } + if (providerRegistry.listProviders().any { it.name.equals(name, ignoreCase = true) }) { + return@post call.respond( + HttpStatusCode.Conflict, + ErrorResponse("Provider name already exists") + ) + } + + val added = registerOverrideProvider(overrideEntry, providerRegistry) + ?: return@post call.respond( + HttpStatusCode.BadRequest, + ErrorResponse("Failed to register override") + ) + configStore.update { current -> + current.providerOverrides.add(overrideEntry) + current + } + call.respond(overrideEntry) + } + delete("/{name}") { + val name = call.parameters["name"] + ?: return@delete call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing name")) + val overrideEntry = configStore.load().providerOverrides.firstOrNull { + it.name.equals(name, ignoreCase = true) + } ?: return@delete call.respond( + HttpStatusCode.NotFound, + ErrorResponse("Override not found") + ) + configStore.update { current -> + current.providerOverrides.removeIf { it.name.equals(name, ignoreCase = true) } + current + } + providerRegistry.removeByName(overrideEntry.name) + call.respond(overrideEntry) + } + } post("/register") { val request = call.receive() val api = providerRegistry.registerByClassName(request.className) @@ -499,11 +616,60 @@ private fun resolveProjectRoot(): Path { } } +private fun cleanupTempPluginArchives(dataDir: Path) { + val cutoffMs = System.currentTimeMillis() - 24L * 60 * 60 * 1000 + val dir = dataDir.toFile() + val candidates = dir.listFiles { file -> + file.isFile && + file.name.endsWith(".cs3", ignoreCase = true) && + (file.name.startsWith("plugin-download-") || file.name.startsWith("plugin-upload-")) + } ?: return + candidates.forEach { file -> + if (file.lastModified() <= cutoffMs) { + if (file.delete()) { + Log.i("Server", "Deleted stale plugin archive: ${file.absolutePath}") + } + } + } +} + private fun resolveProvider(name: String?): com.lagradost.cloudstream3.MainAPI? { if (name.isNullOrBlank()) return null return APIHolder.getApiFromNameNull(name) } +private fun findBaseProviderByClassName(className: String): com.lagradost.cloudstream3.MainAPI? { + val key = className.trim() + if (key.isBlank()) return null + return synchronized(APIHolder.allProviders) { + APIHolder.allProviders.firstOrNull { api -> + if (!api.canBeOverridden) return@firstOrNull false + val qualified = api::class.qualifiedName ?: api::class.java.name + val simple = api::class.simpleName ?: api::class.java.simpleName + qualified == key || simple == key + } + } +} + +private fun registerOverrideProvider( + overrideEntry: ProviderOverride, + providerRegistry: ProviderRegistry +): com.lagradost.cloudstream3.MainAPI? { + val base = findBaseProviderByClassName(overrideEntry.parentClassName) ?: return null + if (!base.canBeOverridden) return null + val url = overrideEntry.url.trim().trimEnd('/') + if (url.isBlank()) return null + val lang = overrideEntry.lang.trim().ifBlank { base.lang } + val instance = base::class.java.getDeclaredConstructor().newInstance().apply { + name = overrideEntry.name.trim() + mainUrl = url + this.lang = lang + canBeOverridden = false + sourcePlugin = base.sourcePlugin + } + return if (providerRegistry.registerCustomProvider(instance)) instance else null +} + private fun normalizeRepositories(configStore: ConfigStore) { configStore.update { config -> val normalized = config.repositories.map { repo -> @@ -534,6 +700,17 @@ private fun loadPluginsOnStartup(configStore: ConfigStore) { } } +private fun applyProviderOverrides(configStore: ConfigStore, providerRegistry: ProviderRegistry) { + val overrides = configStore.load().providerOverrides + if (overrides.isEmpty()) return + overrides.forEach { overrideEntry -> + val added = registerOverrideProvider(overrideEntry, providerRegistry) + if (added == null) { + Log.w("Providers", "Failed to apply provider override: ${overrideEntry.name}") + } + } +} + private fun findRepository(configStore: ConfigStore, idOrUrl: String): RepositoryData? { val repos = configStore.load().repositories return repos.firstOrNull { it.id == idOrUrl || it.url == idOrUrl } @@ -710,29 +887,37 @@ private suspend fun autoUpdatePlugins(configStore: ConfigStore, dataDir: Path): private suspend fun runExtractor(url: String, referer: String?): ExtractorResponse { return withContext(Dispatchers.IO) { - val links = mutableListOf() - val subtitles = mutableListOf() + val links = mutableListOf() + val subtitles = mutableListOf() + val subtitleCallback: (SubtitleFile?) -> Unit = { subtitle -> + if (subtitle != null) subtitles.add(subtitle) + } + val linkCallback: (ExtractorLink?) -> Unit = { link -> + if (link != null) links.add(link) + } val result = runCatching { loadExtractor( url = url, referer = referer, - subtitleCallback = { subtitles.add(it) }, - callback = { links.add(it) } + subtitleCallback = subtitleCallback as (SubtitleFile) -> Unit, + callback = linkCallback as (ExtractorLink) -> Unit ) } + val safeLinks = links.filterNotNull() + val safeSubtitles = subtitles.filterNotNull() if (result.isFailure) { val error = result.exceptionOrNull() return@withContext ExtractorResponse( success = false, - links = links.map { it.toDto() }, - subtitles = subtitles.map { it.toDto() }, + links = safeLinks.map { it.toDto() }, + subtitles = safeSubtitles.map { it.toDto() }, error = error?.message ?: "Extractor failed", ) } ExtractorResponse( success = result.getOrNull() == true, - links = links.map { it.toDto() }, - subtitles = subtitles.map { it.toDto() }, + links = safeLinks.map { it.toDto() }, + subtitles = safeSubtitles.map { it.toDto() }, ) } } @@ -742,17 +927,20 @@ private suspend fun collectExtractorLinks( referer: String? ): Pair, String?> { return withContext(Dispatchers.IO) { - val links = mutableListOf() + val links = mutableListOf() + val linkCallback: (ExtractorLink?) -> Unit = { link -> + if (link != null) links.add(link) + } val result = runCatching { loadExtractor( url = url, referer = referer, subtitleCallback = {}, - callback = { links.add(it) } + callback = linkCallback as (ExtractorLink) -> Unit ) } val error = result.exceptionOrNull()?.message - links to error + links.filterNotNull() to error } } @@ -764,48 +952,7 @@ private fun selectProxyLink(links: List, index: Int?): ExtractorL private suspend fun proxyExtractorLink(call: io.ktor.server.application.ApplicationCall, link: ExtractorLink) { val requestHeaders = buildProxyHeaders(link, call.request.headers) - val connection = withContext(Dispatchers.IO) { - (URL(link.url).openConnection() as HttpURLConnection).apply { - instanceFollowRedirects = true - requestMethod = "GET" - requestHeaders.forEach { (key, value) -> - setRequestProperty(key, value) - } - connect() - } - } - val statusCode = connection.responseCode - call.response.status(HttpStatusCode.fromValue(statusCode)) - connection.contentType?.let { call.response.headers.append(HttpHeaders.ContentType, it) } - if (connection.contentLengthLong >= 0) { - call.response.headers.append(HttpHeaders.ContentLength, connection.contentLengthLong.toString()) - } - connection.getHeaderField("Accept-Ranges")?.let { - call.response.headers.append(HttpHeaders.AcceptRanges, it) - } - connection.getHeaderField("Content-Range")?.let { - call.response.headers.append(HttpHeaders.ContentRange, it) - } - connection.getHeaderField("Content-Disposition")?.let { - call.response.headers.append(HttpHeaders.ContentDisposition, it) - } - connection.getHeaderField("Cache-Control")?.let { - call.response.headers.append(HttpHeaders.CacheControl, it) - } - - call.respondOutputStream { - withContext(Dispatchers.IO) { - val input = if (statusCode >= 400) { - connection.errorStream ?: connection.inputStream - } else { - connection.inputStream - } - input.use { stream -> - stream.copyTo(this@respondOutputStream) - } - } - } - connection.disconnect() + proxyUrl(call, link.url, requestHeaders) } private fun buildProxyHeaders( @@ -823,31 +970,257 @@ private fun buildProxyHeaders( return headers } +private fun buildDirectHeaders( + requestHeaders: io.ktor.http.Headers, + referer: String?, + baseHeaders: Map, + userAgent: String? +): Map { + var headers = baseHeaders + if (!referer.isNullOrBlank() && headers.keys.none { it.equals("Referer", ignoreCase = true) }) { + headers = headers + mapOf("Referer" to referer) + } + if (!userAgent.isNullOrBlank() && headers.keys.none { it.equals("User-Agent", ignoreCase = true) }) { + headers = headers + mapOf("User-Agent" to userAgent) + } + if (headers.keys.none { it.equals("User-Agent", ignoreCase = true) }) { + headers = headers + mapOf("User-Agent" to USER_AGENT) + } + val range = requestHeaders[HttpHeaders.Range] + if (range != null && headers.keys.none { it.equals(HttpHeaders.Range, ignoreCase = true) }) { + headers = headers + mapOf(HttpHeaders.Range to range) + } + return headers +} + +private fun decodeHeadersParam(encoded: String?): Map { + if (encoded.isNullOrBlank()) return emptyMap() + return runCatching { + val decoded = String(java.util.Base64.getDecoder().decode(encoded)) + mapper.readValue>(decoded) + }.getOrElse { emptyMap() } +} + +private suspend fun proxyUrl( + call: io.ktor.server.application.ApplicationCall, + url: String, + requestHeaders: Map, + method: HttpMethod = HttpMethod.Get +) { + val upstreamMethod = if (method == HttpMethod.Head) HttpMethod.Get else method + val connection = withContext(Dispatchers.IO) { + (URL(url).openConnection() as HttpURLConnection).apply { + instanceFollowRedirects = true + requestMethod = upstreamMethod.value + requestHeaders.forEach { (key, value) -> + setRequestProperty(key, value) + } + connect() + } + } + val statusCode = connection.responseCode + call.response.status(HttpStatusCode.fromValue(statusCode)) + val contentType = connection.contentType + val isM3u8 = isM3u8Response(url, contentType) + contentType?.let { call.response.headers.append(HttpHeaders.ContentType, it) } + if (!isM3u8 && connection.contentLengthLong >= 0) { + if (call.response.headers[HttpHeaders.ContentLength] == null) { + call.response.headers.append(HttpHeaders.ContentLength, connection.contentLengthLong.toString()) + } + } + connection.getHeaderField("Accept-Ranges")?.let { + call.response.headers.append(HttpHeaders.AcceptRanges, it) + } + connection.getHeaderField("Content-Range")?.let { + call.response.headers.append(HttpHeaders.ContentRange, it) + } + connection.getHeaderField("Content-Disposition")?.let { + call.response.headers.append(HttpHeaders.ContentDisposition, it) + } + connection.getHeaderField("Cache-Control")?.let { + call.response.headers.append(HttpHeaders.CacheControl, it) + } + + if (method == HttpMethod.Head) { + runCatching { connection.inputStream.close() } + connection.disconnect() + return + } + + val inputStream = if (statusCode >= 400) { + connection.errorStream ?: connection.inputStream + } else { + connection.inputStream + } + + if (isM3u8) { + val playlist = withContext(Dispatchers.IO) { + inputStream.bufferedReader().use { it.readText() } + } + val baseUri = URI(url) + val proxyBase = buildProxyBase(call) + val referer = requestHeaders.entries.firstOrNull { it.key.equals("Referer", ignoreCase = true) }?.value + ?: baseUri.toString() + val userAgent = requestHeaders.entries.firstOrNull { it.key.equals("User-Agent", ignoreCase = true) }?.value + val rewritten = rewriteM3u8( + playlist, + baseUri, + proxyBase, + sanitizeProxyHeaders(requestHeaders), + referer, + userAgent + ) + connection.disconnect() + call.respondText( + text = rewritten, + contentType = io.ktor.http.ContentType.parse( + contentType ?: "application/vnd.apple.mpegurl" + ) + ) + return + } + + try { + call.respondOutputStream { + try { + withContext(Dispatchers.IO) { + inputStream.use { stream -> + stream.copyTo(this@respondOutputStream) + } + } + } catch (e: io.ktor.util.cio.ChannelWriteException) { + Log.w("Proxy", "Client closed proxy connection early: ${e.message}") + } + } + } catch (e: io.ktor.util.cio.ChannelWriteException) { + Log.w("Proxy", "Client closed proxy connection early: ${e.message}") + } finally { + connection.disconnect() + } +} + +private fun isM3u8Response(url: String, contentType: String?): Boolean { + val normalized = contentType?.lowercase().orEmpty() + if (normalized.contains("application/vnd.apple.mpegurl")) return true + if (normalized.contains("application/x-mpegurl")) return true + if (normalized.contains("audio/mpegurl")) return true + return url.lowercase().contains(".m3u8") +} + +private fun buildProxyBase(call: io.ktor.server.application.ApplicationCall): String { + val headers = call.request.headers + val scheme = headers["X-Forwarded-Proto"] + ?: if (headers["X-Forwarded-Ssl"]?.equals("on", ignoreCase = true) == true) "https" else "http" + val host = headers["X-Forwarded-Host"] + ?: headers[HttpHeaders.Host] + ?: "127.0.0.1:8080" + return "$scheme://$host" +} + +private fun sanitizeProxyHeaders(headers: Map): Map { + return headers.filterKeys { key -> + !key.equals("Host", ignoreCase = true) && + !key.equals("Range", ignoreCase = true) + } +} + +private fun encodeHeadersParam(headers: Map): String? { + if (headers.isEmpty()) return null + val json = mapper.writeValueAsString(headers) + return Base64.getEncoder().encodeToString(json.toByteArray()) +} + +private fun buildProxyUrl( + proxyBase: String, + targetUrl: String, + referer: String?, + headers: Map, + userAgent: String? +): String { + val params = mutableListOf("url" to targetUrl) + if (!referer.isNullOrBlank()) params.add("referer" to referer) + if (!userAgent.isNullOrBlank()) params.add("userAgent" to userAgent) + encodeHeadersParam(headers)?.let { params.add("headers" to it) } + val encoded = params.joinToString("&") { (key, value) -> + "${URLEncoder.encode(key, Charsets.UTF_8)}=${URLEncoder.encode(value, Charsets.UTF_8)}" + } + return "$proxyBase/proxy?$encoded" +} + +private fun rewriteM3u8( + playlist: String, + baseUri: URI, + proxyBase: String, + headers: Map, + referer: String?, + userAgent: String? +): String { + val uriRegex = Regex("""URI="([^"]+)"""") + return playlist.lineSequence().joinToString("\n") { line -> + val trimmed = line.trim() + if (trimmed.isEmpty()) { + line + } else if (trimmed.startsWith("#")) { + uriRegex.replace(line) { match -> + val raw = match.groupValues[1] + val proxied = proxifyM3u8Url(raw, baseUri, proxyBase, headers, referer, userAgent) + "URI=\"$proxied\"" + } + } else { + proxifyM3u8Url(trimmed, baseUri, proxyBase, headers, referer, userAgent) + } + } +} + +private fun proxifyM3u8Url( + rawUrl: String, + baseUri: URI, + proxyBase: String, + headers: Map, + referer: String?, + userAgent: String? +): String { + val absolute = if (rawUrl.startsWith("http://") || rawUrl.startsWith("https://")) { + rawUrl + } else { + baseUri.resolve(rawUrl).toString() + } + return buildProxyUrl(proxyBase, absolute, referer ?: baseUri.toString(), headers, userAgent) +} + private suspend fun runLoadLinks(api: com.lagradost.cloudstream3.MainAPI, request: LoadLinksRequest): ExtractorResponse { return withContext(Dispatchers.IO) { - val links = mutableListOf() - val subtitles = mutableListOf() + val links = mutableListOf() + val subtitles = mutableListOf() + val subtitleCallback: (SubtitleFile?) -> Unit = { subtitle -> + if (subtitle != null) subtitles.add(subtitle) + } + val linkCallback: (ExtractorLink?) -> Unit = { link -> + if (link != null) links.add(link) + } val result = runCatching { api.loadLinks( data = request.data, isCasting = request.isCasting, - subtitleCallback = { subtitles.add(it) }, - callback = { links.add(it) } + subtitleCallback = subtitleCallback as (SubtitleFile) -> Unit, + callback = linkCallback as (ExtractorLink) -> Unit ) } + val safeLinks = links.filterNotNull() + val safeSubtitles = subtitles.filterNotNull() if (result.isFailure) { val error = result.exceptionOrNull() return@withContext ExtractorResponse( success = false, - links = links.map { it.toDto() }, - subtitles = subtitles.map { it.toDto() }, + links = safeLinks.map { it.toDto() }, + subtitles = safeSubtitles.map { it.toDto() }, error = error?.message ?: "Load links failed", ) } ExtractorResponse( success = result.getOrNull() == true, - links = links.map { it.toDto() }, - subtitles = subtitles.map { it.toDto() }, + links = safeLinks.map { it.toDto() }, + subtitles = safeSubtitles.map { it.toDto() }, ) } } diff --git a/server/src/main/kotlin/com/lagradost/cloudstream3/utils/DataStore.kt b/server/src/main/kotlin/com/lagradost/cloudstream3/utils/DataStore.kt new file mode 100644 index 000000000..aca0352d0 --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/utils/DataStore.kt @@ -0,0 +1,206 @@ +package com.lagradost.cloudstream3.utils + +import android.content.Context +import android.content.SharedPreferences +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.json.JsonMapper +import com.fasterxml.jackson.module.kotlin.kotlinModule +import com.lagradost.cloudstream3.mvvm.logError +import java.util.concurrent.ConcurrentHashMap +import kotlin.reflect.KClass +import kotlin.reflect.KProperty + +const val DOWNLOAD_HEADER_CACHE = "download_header_cache" +const val DOWNLOAD_EPISODE_CACHE = "download_episode_cache" +const val VIDEO_PLAYER_BRIGHTNESS = "video_player_alpha_key" +const val USER_SELECTED_HOMEPAGE_API = "home_api_used" +const val USER_PROVIDER_API = "user_custom_sites" +const val PREFERENCES_NAME = "rebuild_preference" + +class PreferenceDelegate( + val key: String, + val default: T, +) { + private val klass: KClass = default::class + private var cache: T? = null + + operator fun getValue(self: Any?, property: KProperty<*>) = + cache ?: DataStore.getKeyGlobal(key, klass.java).also { newCache -> + if (newCache != null) cache = newCache + } ?: default + + operator fun setValue(self: Any?, property: KProperty<*>, t: T?) { + cache = t + if (t == null) { + DataStore.removeKeyGlobal(key) + } else { + DataStore.setKeyGlobal(key, t) + } + } +} + +data class Editor( + val editor: SharedPreferences.Editor +) { + fun setKeyRaw(path: String, value: T) { + @Suppress("UNCHECKED_CAST") + if (isStringSet(value)) { + editor.putStringSet(path, value as Set) + } else { + when (value) { + is Boolean -> editor.putBoolean(path, value) + is Int -> editor.putInt(path, value) + is String -> editor.putString(path, value) + is Float -> editor.putFloat(path, value) + is Long -> editor.putLong(path, value) + } + } + } + + private fun isStringSet(value: Any?): Boolean { + if (value is Set<*>) { + return value.filterIsInstance().size == value.size + } + return false + } + + fun apply() { + editor.apply() + System.gc() + } +} + +object DataStore { + val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() + + private val memoryStore = ConcurrentHashMap() + private const val MODE_PRIVATE = 0 + + private fun getPreferences(context: Context): SharedPreferences { + return context.getSharedPreferences(PREFERENCES_NAME, MODE_PRIVATE) + } + + fun Context.getSharedPrefs(): SharedPreferences { + return getPreferences(this) + } + + fun getFolderName(folder: String, path: String): String { + return "${folder}/${path}" + } + + fun editor(context: Context, isEditingAppSettings: Boolean = false): Editor { + val editor = context.getSharedPrefs().edit() + return Editor(editor) + } + + fun Context.getDefaultSharedPrefs(): SharedPreferences { + return getSharedPrefs() + } + + fun Context.getKeys(folder: String): List { + return this.getSharedPrefs().getAll().keys.filter { it.startsWith(folder) } + } + + fun Context.removeKey(folder: String, path: String) { + removeKey(getFolderName(folder, path)) + } + + fun Context.containsKey(folder: String, path: String): Boolean { + return containsKey(getFolderName(folder, path)) + } + + fun Context.containsKey(path: String): Boolean { + val prefs = getSharedPrefs() + return prefs.contains(path) + } + + fun Context.removeKey(path: String) { + try { + val prefs = getSharedPrefs() + if (prefs.contains(path)) { + prefs.edit().remove(path).apply() + } + } catch (e: Exception) { + logError(e) + } + } + + fun Context.removeKeys(folder: String): Int { + val keys = getKeys("$folder/") + try { + getSharedPrefs().edit().apply { + keys.forEach { value -> + remove(value) + } + }.apply() + return keys.size + } catch (e: Exception) { + logError(e) + return 0 + } + } + + fun Context.setKey(path: String, value: T) { + try { + getSharedPrefs().edit().putString(path, mapper.writeValueAsString(value)).apply() + } catch (e: Exception) { + logError(e) + } + } + + fun Context.getKey(path: String, valueType: Class): T? { + return try { + val json: String = getSharedPrefs().getString(path, null) ?: return null + json.toKotlinObject(valueType) + } catch (e: Exception) { + null + } + } + + fun Context.setKey(folder: String, path: String, value: T) { + setKey(getFolderName(folder, path), value) + } + + inline fun String.toKotlinObject(): T { + return mapper.readValue(this, T::class.java) + } + + fun String.toKotlinObject(valueType: Class): T { + return mapper.readValue(this, valueType) + } + + inline fun Context.getKey(path: String, defVal: T?): T? { + return try { + val json: String = getSharedPrefs().getString(path, null) ?: return defVal + json.toKotlinObject() + } catch (e: Exception) { + null + } + } + + inline fun Context.getKey(path: String): T? { + return getKey(path, null) + } + + inline fun Context.getKey(folder: String, path: String): T? { + return getKey(getFolderName(folder, path), null) + } + + inline fun Context.getKey(folder: String, path: String, defVal: T?): T? { + return getKey(getFolderName(folder, path), defVal) ?: defVal + } + + fun setKeyGlobal(path: String, value: T) { + memoryStore[path] = mapper.writeValueAsString(value) + } + + fun getKeyGlobal(path: String, valueType: Class): T? { + val json = memoryStore[path] ?: return null + return runCatching { mapper.readValue(json, valueType) }.getOrNull() + } + + fun removeKeyGlobal(path: String) { + memoryStore.remove(path) + } +} diff --git a/server/ui/package-lock.json b/server/ui/package-lock.json index a426b35c5..fee612bb5 100644 --- a/server/ui/package-lock.json +++ b/server/ui/package-lock.json @@ -11,7 +11,8 @@ "@tailwindcss/vite": "^4.1.18", "daisyui": "^5.5.14", "svelte-spa-router": "^4.0.1", - "tailwindcss": "^4.1.18" + "tailwindcss": "^4.1.18", + "vidstack": "^0.6.15" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", @@ -484,6 +485,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@maverick-js/signals": { + "version": "5.11.5", + "resolved": "https://registry.npmjs.org/@maverick-js/signals/-/signals-5.11.5.tgz", + "integrity": "sha512-/GO94awrwN9ROYZDMTeByordjvbhcm3CMvB/2aL/sEUy9Va8nM/2GmNgOOe+rrooTGnz8/DzO73xomuBRrnYWw==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.56.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", @@ -1627,6 +1634,28 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/maverick.js": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/maverick.js/-/maverick.js-0.37.0.tgz", + "integrity": "sha512-1Dk/9rienLiihlktVvH04ADC2UJTMflC1fOMVQCCaQAaz7hgzDI5i0p/arFbDM52hFFiIcq4RdXtYz47SgsLgw==", + "license": "MIT", + "dependencies": { + "@maverick-js/signals": "^5.10.3", + "type-fest": "^3.8.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/media-captions": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/media-captions/-/media-captions-0.0.18.tgz", + "integrity": "sha512-JW18P6FuHdyLSGwC4TQ0kF3WdNj/+wMw2cKOb8BnmY6vSJGtnwJ+vkYj+IjHOV34j3XMc70HDeB/QYKR7E7fuQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -1901,6 +1930,18 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1923,6 +1964,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/vidstack": { + "version": "0.6.15", + "resolved": "https://registry.npmjs.org/vidstack/-/vidstack-0.6.15.tgz", + "integrity": "sha512-pI2aixBuOpu/LSnRgNJ40tU/KFW+x1X+O2bW1hz946ZZShDM5oqRXF9pavDOuckHAHPgUN9HYUr9vUNTBUPF1Q==", + "license": "MIT", + "dependencies": { + "maverick.js": "0.37.0", + "media-captions": "0.0.18", + "type-fest": "^3.8.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/server/ui/package.json b/server/ui/package.json index 4703c5098..d951ff8a4 100644 --- a/server/ui/package.json +++ b/server/ui/package.json @@ -22,6 +22,7 @@ "@tailwindcss/vite": "^4.1.18", "daisyui": "^5.5.14", "svelte-spa-router": "^4.0.1", - "tailwindcss": "^4.1.18" + "tailwindcss": "^4.1.18", + "vidstack": "^0.6.15" } } diff --git a/server/ui/src/App.svelte b/server/ui/src/App.svelte index 435a89d6f..3645ed03c 100644 --- a/server/ui/src/App.svelte +++ b/server/ui/src/App.svelte @@ -6,6 +6,8 @@ import Search from './pages/Search.svelte'; import Settings from './pages/Settings.svelte'; import PluginManager from './pages/PluginManager.svelte'; + import Details from './pages/Details.svelte'; + import Play from './pages/Play.svelte'; import { theme } from './stores/theme'; import { onMount } from 'svelte'; @@ -14,6 +16,8 @@ '/search': Search, '/settings': Settings, '/plugins': PluginManager, + '/details': Details, + '/play': Play, }; onMount(() => { diff --git a/server/ui/src/api/index.ts b/server/ui/src/api/index.ts index 596769a3c..7e7bf5806 100644 --- a/server/ui/src/api/index.ts +++ b/server/ui/src/api/index.ts @@ -1,8 +1,11 @@ +const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8080'; +export const API_BASE_URL = RAW_API_BASE.replace(/\/+$/, ''); + export class CloudstreamAPI { private baseUrl: string; - constructor(baseUrl: string = '/api') { - this.baseUrl = baseUrl; + constructor(baseUrl: string = API_BASE_URL) { + this.baseUrl = baseUrl.replace(/\/+$/, ''); } private async request(endpoint: string, options: RequestInit = {}): Promise { @@ -75,11 +78,11 @@ export class CloudstreamAPI { }); } - async removePlugin(internalName: string): Promise { + async removePlugin(request: { filePath?: string; repositoryUrl?: string; internalName?: string }): Promise { return this.request('/plugins', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ internalName }), + body: JSON.stringify(request), }); } @@ -97,8 +100,39 @@ export class CloudstreamAPI { return this.request('/providers'); } - async getProviderMainPage(providerName: string): Promise { - return this.request(`/providers/${providerName}/main-page`); + async getProviderOverrides(): Promise { + return this.request('/providers/overrides'); + } + + async addProviderOverride(payload: { + parentClassName: string; + name: string; + url: string; + lang?: string; + }): Promise { + return this.request('/providers/overrides', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + } + + async removeProviderOverride(name: string): Promise { + return this.request(`/providers/overrides/${encodeURIComponent(name)}`, { + method: 'DELETE', + }); + } + + async getProviderMainPages(providerName: string): Promise { + return this.request(`/providers/${providerName}/main-pages`); + } + + async getProviderMainPage(providerName: string, options: { data?: string; page?: number } = {}): Promise { + const params = new URLSearchParams(); + if (options.data) params.set('data', options.data); + if (options.page) params.set('page', String(options.page)); + const query = params.toString(); + return this.request(`/providers/${providerName}/main-page${query ? `?${query}` : ''}`); } async searchProvider(providerName: string, query: string): Promise { @@ -109,6 +143,14 @@ export class CloudstreamAPI { async loadMedia(providerName: string, url: string): Promise { return this.request(`/providers/${providerName}/load?url=${encodeURIComponent(url)}`); } + + async getProviderLinks(providerName: string, data: string, isCasting: boolean = false): Promise { + return this.request(`/providers/${providerName}/links`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data, isCasting }), + }); + } } export const api = new CloudstreamAPI(); diff --git a/server/ui/src/app.css b/server/ui/src/app.css index 9e7ebfa12..299e26afb 100644 --- a/server/ui/src/app.css +++ b/server/ui/src/app.css @@ -1,9 +1,9 @@ @import "tailwindcss"; - -@import "tailwindcss"; +@import "vidstack/styles/defaults.css"; +@import "vidstack/styles/community-skin/video.css"; @plugin "daisyui" { - themes: forest, dracula, dim, sunset, autumn, synthwave, pastel, nord, coffee, night, lemonade; + themes: forest, dracula, black, sunset, autumn, synthwave, retro, nord, coffee, night, lemonade, aqua; } @theme { @@ -19,6 +19,24 @@ } } +:global(media-player.app-player) { + --video-bg: hsl(var(--b1)); + --video-border: 1px solid hsl(var(--bc) / 0.12); + --video-border-radius: 16px; + --video-brand: hsl(var(--p)); + --video-controls-color: hsl(var(--bc)); + --video-scrim-bg: hsl(var(--b1) / 0.2); + --video-font-family: var(--font-body); + --media-focus-ring: 0 0 0 3px hsl(var(--p) / 0.35); + --media-tooltip-bg-color: hsl(var(--b1)); + --media-tooltip-color: hsl(var(--bc)); + --media-time-color: hsl(var(--bc)); + --media-slider-track-bg: hsl(var(--bc) / 0.2); + --media-menu-bg: hsl(var(--b1)); + --media-menu-border: 1px solid hsl(var(--bc) / 0.1); + --media-menu-color: hsl(var(--bc)); +} + /* Scrollbar Styling */ ::-webkit-scrollbar { width: 8px; @@ -31,4 +49,4 @@ ::-webkit-scrollbar-thumb { @apply bg-base-content/20 rounded-full hover:bg-base-content/40 transition-colors; -} \ No newline at end of file +} diff --git a/server/ui/src/assets/poster-fallback.svg b/server/ui/src/assets/poster-fallback.svg new file mode 100644 index 000000000..4d98f7cef --- /dev/null +++ b/server/ui/src/assets/poster-fallback.svg @@ -0,0 +1,8 @@ + + + + + + + No Image + diff --git a/server/ui/src/assets/svelte.svg b/server/ui/src/assets/svelte.svg deleted file mode 100644 index c5e08481f..000000000 --- a/server/ui/src/assets/svelte.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/server/ui/src/components/layout/Sidebar.svelte b/server/ui/src/components/layout/Sidebar.svelte index 33f7a1b16..54388c26d 100644 --- a/server/ui/src/components/layout/Sidebar.svelte +++ b/server/ui/src/components/layout/Sidebar.svelte @@ -1,5 +1,10 @@