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..1bd7285e3 --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/Models.kt @@ -0,0 +1,281 @@ +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 description: 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(), + val providerOverrides: MutableList = mutableListOf(), +) + +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, + val headers: Map? = null, + val userAgent: String? = null, +) + +data class LoadLinksRequest( + val data: String, + val isCasting: Boolean = false, +) + +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, + 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 className: String, + val canBeOverridden: 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, + className = this::class.qualifiedName ?: this::class.java.name, + canBeOverridden = canBeOverridden, + 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..cebe00cc6 --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/ProviderRegistry.kt @@ -0,0 +1,64 @@ +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 + if (addProvider(instance)) instance else null + }.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 + } + + 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 false + APIHolder.allProviders.add(api) + } + APIHolder.addPluginMapping(api) + api.init() + return true + } + + 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..c0db64cec --- /dev/null +++ b/server/src/main/kotlin/com/lagradost/cloudstream3/Server.kt @@ -0,0 +1,1247 @@ +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 +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.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 +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.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() { + 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) + cleanupTempPluginArchives(dataDir) + applyProviderOverrides(configStore, providerRegistry) + + 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, + description = manifest?.description, + 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") { + 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)) + } + proxyExtractorLink(call, selected) + } + } + + route("/providers") { + 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) + ?: 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 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 -> + 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 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 } +} + +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 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 = 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 = safeLinks.map { it.toDto() }, + subtitles = safeSubtitles.map { it.toDto() }, + error = error?.message ?: "Extractor failed", + ) + } + ExtractorResponse( + success = result.getOrNull() == true, + links = safeLinks.map { it.toDto() }, + subtitles = safeSubtitles.map { it.toDto() }, + ) + } +} + +private suspend fun collectExtractorLinks( + url: String, + referer: String? +): Pair, String?> { + return withContext(Dispatchers.IO) { + val links = mutableListOf() + val linkCallback: (ExtractorLink?) -> Unit = { link -> + if (link != null) links.add(link) + } + val result = runCatching { + loadExtractor( + url = url, + referer = referer, + subtitleCallback = {}, + callback = linkCallback as (ExtractorLink) -> Unit + ) + } + val error = result.exceptionOrNull()?.message + links.filterNotNull() 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) + proxyUrl(call, link.url, requestHeaders) +} + +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 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 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 = 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 = safeLinks.map { it.toDto() }, + subtitles = safeSubtitles.map { it.toDto() }, + error = error?.message ?: "Load links failed", + ) + } + ExtractorResponse( + success = result.getOrNull() == true, + links = safeLinks.map { it.toDto() }, + subtitles = safeSubtitles.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/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/.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..fee612bb5 --- /dev/null +++ b/server/ui/package-lock.json @@ -0,0 +1,2084 @@ +{ + "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", + "vidstack": "^0.6.15" + }, + "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/@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", + "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/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", + "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/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", + "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/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", + "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..d951ff8a4 --- /dev/null +++ b/server/ui/package.json @@ -0,0 +1,28 @@ +{ + "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", + "vidstack": "^0.6.15" + } +} diff --git a/server/ui/src/App.svelte b/server/ui/src/App.svelte new file mode 100644 index 000000000..3645ed03c --- /dev/null +++ b/server/ui/src/App.svelte @@ -0,0 +1,47 @@ + + +
+ + + + + +
+ +
+
+ + diff --git a/server/ui/src/api/index.ts b/server/ui/src/api/index.ts new file mode 100644 index 000000000..7e7bf5806 --- /dev/null +++ b/server/ui/src/api/index.ts @@ -0,0 +1,156 @@ +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_BASE_URL) { + this.baseUrl = baseUrl.replace(/\/+$/, ''); + } + + 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(request: { filePath?: string; repositoryUrl?: string; internalName?: string }): Promise { + return this.request('/plugins', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(request), + }); + } + + 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 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 { + // 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)}`); + } + + 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 new file mode 100644 index 000000000..299e26afb --- /dev/null +++ b/server/ui/src/app.css @@ -0,0 +1,52 @@ +@import "tailwindcss"; +@import "vidstack/styles/defaults.css"; +@import "vidstack/styles/community-skin/video.css"; + +@plugin "daisyui" { + themes: forest, dracula, black, sunset, autumn, synthwave, retro, nord, coffee, night, lemonade, aqua; +} + +@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"; + } +} + +: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; + 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; +} 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/components/layout/Sidebar.svelte b/server/ui/src/components/layout/Sidebar.svelte new file mode 100644 index 000000000..54388c26d --- /dev/null +++ b/server/ui/src/components/layout/Sidebar.svelte @@ -0,0 +1,85 @@ + + + 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..c6483485d --- /dev/null +++ b/server/ui/src/components/shared/PosterCard.svelte @@ -0,0 +1,50 @@ + + +
onSelect?.()} + onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onSelect?.()} +> +
+ {title} + +
+ +
+
+
+

{title}

+ {#if subtitle} +

{subtitle}

+ {/if} +
+
diff --git a/server/ui/src/components/shared/ProviderPicker.svelte b/server/ui/src/components/shared/ProviderPicker.svelte new file mode 100644 index 000000000..26d3afc74 --- /dev/null +++ b/server/ui/src/components/shared/ProviderPicker.svelte @@ -0,0 +1,128 @@ + + + + + + + + diff --git a/server/ui/src/main.ts b/server/ui/src/main.ts new file mode 100644 index 000000000..25b9e6e7b --- /dev/null +++ b/server/ui/src/main.ts @@ -0,0 +1,13 @@ +import { mount } from 'svelte' +import './app.css' +import App from './App.svelte' +import 'vidstack/define/media-player' +import 'vidstack/define/media-outlet' +import 'vidstack/define/media-poster' +import 'vidstack/define/media-community-skin' + +const app = mount(App, { + target: document.getElementById('app')!, +}) + +export default app diff --git a/server/ui/src/pages/Details.svelte b/server/ui/src/pages/Details.svelte new file mode 100644 index 000000000..1186b2349 --- /dev/null +++ b/server/ui/src/pages/Details.svelte @@ -0,0 +1,374 @@ + + +
+ {#if loading} +
+ +
+ {:else if error} +
+
!
+

Failed to load details

+

{error}

+ +
+ {:else if details} + {@const heroImage = details?.backgroundPosterUrl || details?.posterUrl || queryPoster} + {@const posterImage = details?.posterUrl || queryPoster} + {@const title = details?.name || queryName || 'Details'} + {@const typeLabel = details?.type || queryType} +
+ {#if heroImage} + + {/if} +
+
+ +
+
+
+
+ {#if posterImage} + {title} + {:else} +
+ {/if} +
+
+
+ {#if typeLabel} + {typeLabel} + {/if} + {#if details?.year} + {details.year} + {/if} + {#if details?.duration} + {formatRuntime(details.duration)} + {/if} + {#if details?.contentRating} + {details.contentRating} + {/if} + {#if details?.tags} + {#each details.tags as tag} + {tag} + {/each} + {/if} + {#if details?.score?.data} + Score {Math.round(details.score.data / 10000000) / 10} + {/if} +
+

{title}

+ {#if details?.plot} +

+ {details.plot} +

+ {/if} +
+ {#if !isSeries} + + {/if} +
+
+
+
+
+ + + + {#if isSeries} +
+
+

Seasons

+
+ {#each seasons as season} + + {/each} + {#if seasons.length === 0} +
No seasons available.
+ {/if} +
+
+
+
+

Episodes

+ {#if selectedSeason !== null} + Season {selectedSeason} + {/if} +
+
+ {#each episodes.filter((ep) => ep.season === selectedSeason) as ep} +
+
+
+ {#if ep.posterUrl} + + {:else} +
+ {/if} +
+
Episode {ep.episode}
+
{ep.name}
+ {#if ep.description} +
{ep.description}
+ {/if} +
+ {#if ep.runTime} +
{formatRuntime(ep.runTime)}
+ {/if} + {#if ep.rating} +
Rating {ep.rating}%
+ {/if} + {#if ep.score?.data} +
Score {formatScore(ep.score.data)}
+ {/if} + {#if ep.date} +
Aired {formatDate(ep.date)}
+ {/if} +
+ {#if ep.dub} +
{ep.dub}
+ {/if} +
+
+ +
+
+ {/each} + {#if selectedSeason !== null && episodes.filter((ep) => ep.season === selectedSeason).length === 0} +
No episodes found for this season.
+ {/if} +
+
+
+ {/if} + + {#if details?.recommendations && details.recommendations.length > 0} +
+

Recommendations

+
+
+ {#each details.recommendations as rec} +
+ openRec(rec)} + /> +
+ {/each} +
+
+
+ {/if} + {:else} +
+ Select a title to view details. +
+ {/if} +
+ + diff --git a/server/ui/src/pages/Home.svelte b/server/ui/src/pages/Home.svelte new file mode 100644 index 000000000..083eba6d5 --- /dev/null +++ b/server/ui/src/pages/Home.svelte @@ -0,0 +1,195 @@ + + +
+ + +
+

Browse

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

Failed to load content

+

{error}

+ +
+ {:else if mainPageData} + {#if mainPageData.length === 0} +
+ No main page content available for this provider. +
+ {:else} + + {#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} +
+ openDetails(item)} + /> +
+ {/each} +
+
+
+ {/if} + {/each} +
+ {/if} + {:else} +
+ Select a provider to start browsing. +
+ {/if} +
+ + diff --git a/server/ui/src/pages/Play.svelte b/server/ui/src/pages/Play.svelte new file mode 100644 index 000000000..ad4d6c3b0 --- /dev/null +++ b/server/ui/src/pages/Play.svelte @@ -0,0 +1,219 @@ + + +
+
+ + {#if links.length > 1} + + {/if} +
+ +
+

+ {name || episode || show || 'Playback'} +

+ {#if show && episode} +

{show} • {episode}

+ {:else if show} +

{show}

+ {/if} +
+ + {#if loading} +
+ +
+ {:else if error} +
+ {error} +
+ {:else if playerSrc} + + + {#each subtitles as sub} + + {/each} + + + + + {:else} +
Select a title to play.
+ {/if} +
diff --git a/server/ui/src/pages/PluginManager.svelte b/server/ui/src/pages/PluginManager.svelte new file mode 100644 index 000000000..db06af0af --- /dev/null +++ b/server/ui/src/pages/PluginManager.svelte @@ -0,0 +1,704 @@ + + +
+
+ {#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} + + {/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} + + {/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} + + {/if} +
+
+

{repo.name}

+
{repo.url}
+ {#if repo.description} +
{repo.description}
+ {/if} +
+
+
+ + +
+
+ {/each} +
+
+ {:else if activeTab === 'overrides'} +
+
+
+
+

Add Provider Override

+

+ Create a custom provider by overriding the base URL, name, or language. +

+
+
+
+ + (overrideBaseClass = event.detail.value)} + /> +
+
+ + +
+
+ + +
+
+ + + +
+
+
+ +
+
+
+ +
+

Current Overrides

+ {#if providerOverrides.length === 0} +
+ No overrides added yet. +
+ {:else} +
+ {#each providerOverrides as overrideEntry} +
+
+

{overrideEntry.name}

+
+ Base: {resolveProviderLabel(overrideEntry.parentClassName)} +
+
{overrideEntry.url}
+
Lang: {overrideEntry.lang}
+
+ +
+ {/each} +
+ {/if} +
+
+ {/if} + + + + + + +
diff --git a/server/ui/src/pages/Search.svelte b/server/ui/src/pages/Search.svelte new file mode 100644 index 000000000..40caff038 --- /dev/null +++ b/server/ui/src/pages/Search.svelte @@ -0,0 +1,150 @@ + + +
+ +
+

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} + openDetails(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..7c691771a --- /dev/null +++ b/server/ui/src/stores/index.ts @@ -0,0 +1,48 @@ +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); +const ACTIVE_PROVIDER_KEY = 'cloudstream_active_provider'; + +if (typeof localStorage !== 'undefined') { + activeProvider.subscribe((value) => { + if (value) { + localStorage.setItem(ACTIVE_PROVIDER_KEY, value); + } + }); +} + +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) { + const stored = typeof localStorage !== 'undefined' + ? localStorage.getItem(ACTIVE_PROVIDER_KEY) + : null; + const storedValid = stored && provs.some(p => p.name === stored) ? stored : null; + activeProvider.update(current => { + if (current && provs.some(p => p.name === current)) return current; + if (storedValid) return storedValid; + return 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..33b69ad36 --- /dev/null +++ b/server/ui/src/stores/theme.ts @@ -0,0 +1,43 @@ +import { writable } from 'svelte/store'; + +const THEME_KEY = 'cloudstream_theme'; +const DEFAULT_THEME = 'forest'; + +export const themes = [ + 'forest', + 'dracula', + 'black', + 'sunset', + 'autumn', + 'synthwave', + 'retro', + 'nord', + 'coffee', + 'night', + 'lemonade', + 'aqua' +]; + +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..36b7af5ae --- /dev/null +++ b/server/ui/vite.config.ts @@ -0,0 +1,23 @@ +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() + ], + optimizeDeps: { + exclude: ['vidstack'], + }, + 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")