Compare commits

...

2 commits

Author SHA1 Message Date
Cloudburst
890b30c47d large server change
enhance Home page with provider selection and improved data loading
create Play page for media playback
enhance PluginManager with provider overrides
improve Search page with provider selection
persist active provider in local storage
update theme options
2026-01-22 19:59:49 +01:00
Cloudburst
30df73645b initial server testing 2026-01-22 15:25:09 +01:00
59 changed files with 7795 additions and 1 deletions

View file

@ -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" }

2
server/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
data
config.json

59
server/build.gradle.kts Normal file
View file

@ -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<KotlinJvmCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
freeCompilerArgs.add("-opt-in=com.lagradost.cloudstream3.Prerelease")
}
}
tasks.named<Tar>("distTar") {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
tasks.named<Zip>("distZip") {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}

View file

@ -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<String, Any?>()
override fun getAll(): Map<String, *> = data.toMap()
override fun getString(key: String, defValue: String?): String? =
data[key] as? String ?: defValue
override fun getStringSet(key: String, defValues: Set<String>?): Set<String>? =
@Suppress("UNCHECKED_CAST") (data[key] as? Set<String>) ?: 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<String, Any?>) : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
private val removals = mutableSetOf<String>()
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<String>?): 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)
}
}
}

View file

@ -0,0 +1,32 @@
package android.content
interface SharedPreferences {
fun getAll(): Map<String, *>
fun getString(key: String, defValue: String?): String?
fun getStringSet(key: String, defValues: Set<String>?): Set<String>?
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<String>?): 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?)
}
}

View file

@ -0,0 +1,3 @@
package android.content.res
open class Resources

View file

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

View file

@ -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 }
}

View file

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

View file

@ -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<String> = listOf("*"),
val useJsdelivr: Boolean = false,
)
data class AccountConfig(
val id: String,
val type: String,
val name: String? = null,
val data: Map<String, String> = 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<String> = emptyList(),
val description: String? = null,
val tvTypes: List<String>? = 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<AccountConfig> = mutableListOf(),
val repositories: MutableList<RepositoryData> = mutableListOf(),
val plugins: MutableList<PluginData> = mutableListOf(),
val pluginSettings: MutableMap<String, MutableMap<String, MutableMap<String, Any?>>> = mutableMapOf(),
val providerClasses: MutableList<String> = defaultProviderClasses(),
val providerOverrides: MutableList<ProviderOverride> = 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<String>,
)
data class SitePlugin(
val url: String,
val status: Int,
val version: Int,
val apiVersion: Int,
val name: String,
val internalName: String,
val authors: List<String> = emptyList(),
val description: String? = null,
val repositoryUrl: String? = null,
val tvTypes: List<String>? = 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<String, String>? = 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<String, String> = 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<String>,
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<SitePluginWithVotes>,
)
data class SitePluginWithVotes(
val plugin: SitePlugin,
val votes: Int? = null,
)
data class ExtractorResponse(
val success: Boolean,
val links: List<ExtractorLinkDto>,
val subtitles: List<SubtitleDto>,
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<String, String>,
val allHeaders: Map<String, String>,
val userAgent: String?,
val isM3u8: Boolean,
val isDash: Boolean,
val extractorData: String? = null,
val audioTracks: List<AudioTrackDto> = emptyList(),
val playlist: List<PlayListItemDto>? = null,
)
data class PlayListItemDto(
val url: String,
val durationUs: Long,
)
data class SubtitleDto(
val lang: String,
val url: String,
val headers: Map<String, String>?,
val langTag: String?,
)
data class AudioTrackDto(
val url: String,
val headers: Map<String, String>?,
)
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<String, String>, key: String): String? {
return headers.entries.firstOrNull { it.key.equals(key, ignoreCase = true) }?.value
}
private fun defaultProviderClasses(): MutableList<String> = mutableListOf(
"com.lagradost.cloudstream3.metaproviders.TmdbProvider",
"com.lagradost.cloudstream3.metaproviders.TraktProvider",
)

View file

@ -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<MainAPI> {
val registered = mutableListOf<MainAPI>()
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<MainAPI> = 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 }
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<String, *> = readPrefs()
override fun getString(key: String, defValue: String?): String? =
readPrefs()[key] as? String ?: defValue
override fun getStringSet(key: String, defValues: Set<String>?): Set<String>? {
val value = readPrefs()[key] ?: return defValues
return when (value) {
is Set<*> -> value.filterIsInstance<String>().toSet()
is Collection<*> -> value.filterIsInstance<String>().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<String, Any?> {
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<String, Any?>()
private val removals = mutableSetOf<String>()
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<String>?): 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
}
}
}
}

View file

@ -0,0 +1,11 @@
package com.lagradost.cloudstream3.actions
abstract class VideoClickAction(
val name: String
) {
var sourcePlugin: String? = null
}
object VideoClickActionHolder {
val allVideoClickActions: MutableList<VideoClickAction> = mutableListOf()
}

View file

@ -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
}

View file

@ -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,
)

View file

@ -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<String, LoadedPlugin>()
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
}
}
}

View file

@ -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<Repository>() }.getOrNull()
}
private suspend fun parsePlugins(pluginUrls: String): List<SitePlugin> {
return try {
val response = app.get(convertRawGitUrl(pluginUrls))
tryParseJson<Array<SitePlugin>>(response.text)?.toList() ?: emptyList()
} catch (t: Throwable) {
Log.e("RepositoryManager", "Failed to parse plugins: ${t.message}")
emptyList()
}
}
suspend fun getRepoPlugins(repositoryUrl: String): List<Pair<String, SitePlugin>>? {
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)
}
}
}

View file

@ -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<Result>()?.value ?: 0
}.getOrDefault(0)
}
private data class Result(
val value: Int?
)
}

View file

@ -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<String, Array<AuthData>> = mutableMapOf()
var cachedAccountIds: MutableMap<String, Int> = mutableMapOf()
val syncApis: Array<SyncRepo> = arrayOf(
SyncRepo(malApi),
SyncRepo(aniListApi),
SyncRepo(simklApi),
SyncRepo(localListApi),
)
val subtitleProviders: Array<SubtitleRepo> = arrayOf(
SubtitleRepo(openSubtitlesApi),
SubtitleRepo(addic7ed),
SubtitleRepo(subDlApi),
SubtitleRepo(subSourceApi),
)
val allApis: Array<AuthRepo> = arrayOf(
*syncApis,
*subtitleProviders,
)
fun accounts(prefix: String): Array<AuthData> {
return cachedAccounts[prefix] ?: emptyArray()
}
fun updateAccounts(prefix: String, array: Array<AuthData>) {
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"
}
}
}

View file

@ -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) {}
}

View file

@ -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<String, String>? = 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,
)

View file

@ -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
}

View file

@ -0,0 +1,3 @@
package com.lagradost.cloudstream3.syncproviders
abstract class SubtitleAPI : AuthAPI()

View file

@ -0,0 +1,3 @@
package com.lagradost.cloudstream3.syncproviders
class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api)

View file

@ -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,
}

View file

@ -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<Boolean> =
runCatching {
val status = api.updateStatus(freshAuth(), id, newStatus)
requireLibraryRefresh = true
status
}
suspend fun status(id: String): Result<SyncAPI.AbstractSyncStatus?> =
runCatching { api.status(freshAuth(), id) }
suspend fun load(id: String): Result<SyncAPI.SyncResult?> =
runCatching { api.load(freshAuth(), id) }
suspend fun library(): Result<SyncAPI.LibraryMetadata?> =
runCatching { api.library(freshAuth()) }
}

View file

@ -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"
}

View file

@ -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<T : Any>(
val key: String,
val default: T,
) {
private val klass: KClass<out T> = 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 <T> setKeyRaw(path: String, value: T) {
@Suppress("UNCHECKED_CAST")
if (isStringSet(value)) {
editor.putStringSet(path, value as Set<String>)
} 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<String>().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<String, String>()
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<String> {
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 <T> Context.setKey(path: String, value: T) {
try {
getSharedPrefs().edit().putString(path, mapper.writeValueAsString(value)).apply()
} catch (e: Exception) {
logError(e)
}
}
fun <T> Context.getKey(path: String, valueType: Class<T>): T? {
return try {
val json: String = getSharedPrefs().getString(path, null) ?: return null
json.toKotlinObject(valueType)
} catch (e: Exception) {
null
}
}
fun <T> Context.setKey(folder: String, path: String, value: T) {
setKey(getFolderName(folder, path), value)
}
inline fun <reified T : Any> String.toKotlinObject(): T {
return mapper.readValue(this, T::class.java)
}
fun <T> String.toKotlinObject(valueType: Class<T>): T {
return mapper.readValue(this, valueType)
}
inline fun <reified T : Any> 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 <reified T : Any> Context.getKey(path: String): T? {
return getKey(path, null)
}
inline fun <reified T : Any> Context.getKey(folder: String, path: String): T? {
return getKey(getFolderName(folder, path), null)
}
inline fun <reified T : Any> Context.getKey(folder: String, path: String, defVal: T?): T? {
return getKey(getFolderName(folder, path), defVal) ?: defVal
}
fun <T> setKeyGlobal(path: String, value: T) {
memoryStore[path] = mapper.writeValueAsString(value)
}
fun <T> getKeyGlobal(path: String, valueType: Class<T>): T? {
val json = memoryStore[path] ?: return null
return runCatching { mapper.readValue(json, valueType) }.getOrNull()
}
fun removeKeyGlobal(path: String) {
memoryStore.remove(path)
}
}

24
server/ui/.gitignore vendored Normal file
View file

@ -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?

47
server/ui/README.md Normal file
View file

@ -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)
```

13
server/ui/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ui</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2084
server/ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

28
server/ui/package.json Normal file
View file

@ -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"
}
}

47
server/ui/src/App.svelte Normal file
View file

@ -0,0 +1,47 @@
<script lang="ts">
import Sidebar from './components/layout/Sidebar.svelte';
import ToastContainer from './components/layout/ToastContainer.svelte';
import Router from 'svelte-spa-router';
import Home from './pages/Home.svelte';
import Search from './pages/Search.svelte';
import Settings from './pages/Settings.svelte';
import PluginManager from './pages/PluginManager.svelte';
import Details from './pages/Details.svelte';
import Play from './pages/Play.svelte';
import { theme } from './stores/theme';
import { onMount } from 'svelte';
const routes = {
'/': Home,
'/search': Search,
'/settings': Settings,
'/plugins': PluginManager,
'/details': Details,
'/play': Play,
};
onMount(() => {
theme.init();
});
</script>
<div class="flex h-screen w-full bg-base-300 text-base-content overflow-hidden font-body relative">
<ToastContainer />
<!-- Sidebar -->
<Sidebar />
<!-- Main Content -->
<main class="flex-1 h-full overflow-y-auto relative no-scrollbar">
<Router {routes} />
</main>
</div>
<style>
:global(.no-scrollbar) {
scrollbar-width: none;
-ms-overflow-style: none;
}
:global(.no-scrollbar::-webkit-scrollbar) {
display: none;
}
</style>

156
server/ui/src/api/index.ts Normal file
View file

@ -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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
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<any> {
return this.request('/config');
}
async updateConfig(config: any): Promise<any> {
return this.request('/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
});
}
// --- Repositories ---
async getRepositories(): Promise<any[]> {
return this.request('/repositories');
}
async addRepository(urlOrShortcode: string, name?: string): Promise<any> {
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<any> {
return this.request(`/repositories/${id}`, { method: 'DELETE' });
}
async getRepositoryPlugins(id: string): Promise<any> {
return this.request(`/repositories/${id}/plugins`);
}
async installRepositoryPlugin(repoId: string, internalName: string): Promise<any> {
return this.request(`/repositories/${repoId}/plugins/${internalName}/install`, {
method: 'POST'
});
}
// --- Plugins ---
async getPlugins(): Promise<any[]> {
return this.request('/plugins');
}
async installPlugin(repositoryUrl: string, internalName: string): Promise<any> {
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<any> {
return this.request('/plugins', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
}
async uploadPlugin(file: File): Promise<any> {
const formData = new FormData();
formData.append('file', file);
return this.request('/plugins/local', {
method: 'POST',
body: formData,
});
}
// --- Providers ---
async getProviders(): Promise<any[]> {
return this.request('/providers');
}
async getProviderOverrides(): Promise<any[]> {
return this.request('/providers/overrides');
}
async addProviderOverride(payload: {
parentClassName: string;
name: string;
url: string;
lang?: string;
}): Promise<any> {
return this.request('/providers/overrides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}
async removeProviderOverride(name: string): Promise<any> {
return this.request(`/providers/overrides/${encodeURIComponent(name)}`, {
method: 'DELETE',
});
}
async getProviderMainPages(providerName: string): Promise<any[]> {
return this.request(`/providers/${providerName}/main-pages`);
}
async getProviderMainPage(providerName: string, options: { data?: string; page?: number } = {}): Promise<any> {
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<any> {
// Basic search; robust implementation would handle page pagination
return this.request(`/providers/${providerName}/search?query=${encodeURIComponent(query)}`);
}
async loadMedia(providerName: string, url: string): Promise<any> {
return this.request(`/providers/${providerName}/load?url=${encodeURIComponent(url)}`);
}
async getProviderLinks(providerName: string, data: string, isCasting: boolean = false): Promise<any> {
return this.request(`/providers/${providerName}/links`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data, isCasting }),
});
}
}
export const api = new CloudstreamAPI();

52
server/ui/src/app.css Normal file
View file

@ -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;
}

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="450" viewBox="0 0 300 450">
<rect width="300" height="450" fill="#111827"/>
<rect x="18" y="18" width="264" height="414" rx="16" ry="16" fill="#1f2937" stroke="#374151" stroke-width="4"/>
<rect x="70" y="120" width="160" height="120" rx="8" ry="8" fill="#0f172a" stroke="#374151" stroke-width="3"/>
<circle cx="110" cy="150" r="10" fill="#374151"/>
<path d="M78 220l38-38 28 28 24-24 54 54H78z" fill="#374151"/>
<text x="150" y="300" text-anchor="middle" fill="#9ca3af" font-family="Arial, sans-serif" font-size="20">No Image</text>
</svg>

After

Width:  |  Height:  |  Size: 612 B

View file

@ -0,0 +1,85 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { location, push } from 'svelte-spa-router';
import { API_BASE_URL } from '../../api';
let healthOk = false;
let healthTimer: number | undefined;
const navItems = [
{ label: 'Home', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6', path: '/' },
{ label: 'Search', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z', path: '/search' },
{ label: 'Plugins', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', path: '/plugins' },
{ label: 'Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z', path: '/settings' },
];
function isActive(path: string, currentPath: string) {
if (path === '/') return currentPath === '/';
return currentPath.startsWith(path);
}
async function checkHealth() {
try {
const res = await fetch(`${API_BASE_URL}/health`);
if (!res.ok) {
healthOk = false;
return;
}
const data = await res.json();
healthOk = data?.status === 'ok';
} catch {
healthOk = false;
}
}
onMount(() => {
checkHealth();
healthTimer = window.setInterval(checkHealth, 3000);
});
onDestroy(() => {
if (healthTimer) window.clearInterval(healthTimer);
});
</script>
<aside class="w-20 md:w-64 h-full bg-base-200 border-r border-base-100 flex flex-col transition-all duration-300">
<!-- Logo Area -->
<div class="h-16 flex items-center justify-center md:justify-start md:px-6 border-b border-base-100/50">
<div class="size-10 flex items-center justify-center shrink-0 text-primary">
<svg viewBox="0 0 108 108" class="size-full fill-current scale-150">
<g transform="translate(29.16, 29.16) scale(0.1755477)">
<path d="M 245.05 148.63 C 242.249 148.627 239.463 149.052 236.79 149.89 C 235.151 141.364 230.698 133.63 224.147 127.931 C 217.597 122.233 209.321 118.893 200.65 118.45 C 195.913 105.431 186.788 94.458 174.851 87.427 C 162.914 80.396 148.893 77.735 135.21 79.905 C 121.527 82.074 109.017 88.941 99.84 99.32 C 89.871 95.945 79.051 96.024 69.133 99.545 C 59.215 103.065 50.765 109.826 45.155 118.73 C 39.545 127.634 37.094 138.174 38.2 148.64 L 37.94 148.64 C 30.615 148.64 23.582 151.553 18.403 156.733 C 13.223 161.912 10.31 168.945 10.31 176.27 C 10.31 183.595 13.223 190.628 18.403 195.807 C 23.582 200.987 30.615 203.9 37.94 203.9 L 245.05 203.9 C 252.375 203.9 259.408 200.987 264.587 195.807 C 269.767 190.628 272.68 183.595 272.68 176.27 C 272.68 168.945 269.767 161.912 264.587 156.733 C 259.408 151.553 252.375 148.64 245.05 148.64 Z" />
<path d="M 208.61 125 C 208.61 123.22 208.55 121.45 208.48 119.69 C 205.919 119.01 203.296 118.595 200.65 118.45 C 195.913 105.431 186.788 94.458 174.851 87.427 C 162.914 80.396 148.893 77.735 135.21 79.905 C 121.527 82.074 109.017 88.941 99.84 99.32 C 89.871 95.945 79.051 96.024 69.133 99.545 C 59.215 103.065 50.765 109.826 45.155 118.73 C 39.545 127.634 37.094 138.174 38.2 148.64 L 37.94 148.64 C 30.615 148.64 23.582 151.553 18.403 156.733 C 13.223 161.912 10.31 168.945 10.31 176.27 C 10.31 183.595 13.223 190.628 18.403 195.807 C 23.582 200.987 30.615 203.9 37.94 203.9 L 179 203.9 C 198.116 182.073 208.646 154.015 208.646 125 Z" />
<path d="M 99.84 99.32 C 89.871 95.945 79.051 96.024 69.133 99.545 C 59.215 103.065 50.765 109.826 45.155 118.73 C 39.545 127.634 37.094 138.174 38.2 148.64 L 37.94 148.64 C 30.783 148.665 23.909 151.471 18.779 156.461 C 13.648 161.452 10.653 168.246 10.43 175.399 C 10.207 182.553 12.773 189.52 17.583 194.82 C 22.392 200.121 29.079 203.349 36.22 203.82 C 67.216 202.93 96.673 189.98 118.284 167.742 C 139.895 145.504 151.997 115.689 152 84.68 C 152 83 151.94 81.33 151.87 79.68 C 149.443 79.361 146.998 79.194 144.55 79.18 C 136.095 79.171 127.735 80.962 120.026 84.434 C 112.317 87.907 105.435 92.982 99.84 99.32 Z" />
</g>
</svg>
</div>
<span class="ml-3 font-bold text-lg hidden md:inline-flex items-center gap-2">
CloudStream
<span class="size-2 rounded-full {healthOk ? 'bg-success' : 'bg-base-content/30'}"></span>
</span>
</div>
<!-- Nav Items -->
<nav class="flex-1 py-6 flex flex-col gap-2 px-2 md:px-4">
{#each navItems as item}
<button
onclick={() => push(item.path)}
class="flex items-center gap-4 px-3 py-3 rounded-lg transition-all duration-200 group relative
{isActive(item.path, $location)
? 'bg-base-200 text-primary shadow-inner'
: 'text-base-content/60 hover:text-base-content hover:bg-base-200/50'}"
>
<svg class="size-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={item.icon} />
</svg>
<span class="font-medium hidden md:block">{item.label}</span>
<!-- Active Indicator -->
{#if isActive(item.path, $location)}
<div class="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-primary rounded-r-full"></div>
{/if}
</button>
{/each}
</nav>
</aside>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { toast } from '../../stores/toast';
import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition';
</script>
<div class="toast toast-end toast-bottom z-50">
{#each $toast as t (t.id)}
<div
animate:flip={{duration: 300}}
transition:fly={{y: 20, duration: 300}}
class="alert shadow-lg text-white min-w-[300px] cursor-pointer
{t.type === 'info' ? 'alert-info' : ''}
{t.type === 'success' ? 'alert-success' : ''}
{t.type === 'warning' ? 'alert-warning' : ''}
{t.type === 'error' ? 'alert-error' : ''}"
onclick={() => toast.remove(t.id)}
role="alert"
>
<span class="font-medium text-white">{t.message}</span>
</div>
{/each}
</div>

View file

@ -0,0 +1,44 @@
<script lang="ts">
export let title = 'Confirm Action';
export let message = 'Are you sure?';
export let confirmText = 'Confirm';
export let cancelText = 'Cancel';
export let type: 'warning' | 'error' | 'info' = 'warning';
let dialog: HTMLDialogElement;
let onResolve: ((value: boolean) => void) | null = null;
export function show(): Promise<boolean> {
dialog.showModal();
return new Promise((resolve) => {
onResolve = resolve;
});
}
function handleClose(result: boolean) {
dialog.close();
if (onResolve) {
onResolve(result);
onResolve = null;
}
}
</script>
<dialog bind:this={dialog} class="modal">
<div class="modal-box">
<h3 class="font-bold text-lg {type === 'error' ? 'text-error' : ''}">{title}</h3>
<p class="py-4">{message}</p>
<div class="modal-action">
<button class="btn" onclick={() => handleClose(false)}>{cancelText}</button>
<button
class="btn {type === 'error' ? 'btn-error' : 'btn-primary'}"
onclick={() => handleClose(true)}
>
{confirmText}
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button onclick={() => handleClose(false)}>close</button>
</form>
</dialog>

View file

@ -0,0 +1,50 @@
<script lang="ts">
import fallbackPoster from '../../assets/poster-fallback.svg';
export let title: string;
export let image: string;
export let subtitle: string | undefined = undefined;
export let onSelect: (() => void) | undefined = undefined;
const fallbackPosterSrc = fallbackPoster;
// Fallback for missing images
function handleImageError(e: Event) {
const target = e.target as HTMLImageElement;
if (target.src !== fallbackPosterSrc) {
target.src = fallbackPosterSrc;
}
}
</script>
<div
class="card bg-base-100 shadow-xl hover:scale-105 transition-transform duration-200 cursor-pointer overflow-hidden group h-full"
role="button"
tabindex="0"
onclick={() => onSelect?.()}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onSelect?.()}
>
<figure class="aspect-[2/3] relative">
<img
src={image}
alt={title}
class="w-full h-full object-cover"
onerror={handleImageError}
loading="lazy"
/>
<!-- Hover Overlay -->
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<button class="btn btn-circle btn-primary btn-lg scale-0 group-hover:scale-100 transition-transform duration-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
</button>
</div>
</figure>
<div class="card-body p-3 gap-1">
<h3 class="font-bold text-sm line-clamp-2 leading-tight">{title}</h3>
{#if subtitle}
<p class="text-xs text-base-content/60">{subtitle}</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,128 @@
<script lang="ts">
export let providers: any[] = [];
export let selectedValue: string | null = null;
export let valueKey: string = 'name';
export let title = 'Select Provider';
export let description = '';
export let buttonClass = 'btn btn-sm btn-outline';
export let disabled = false;
export let allowSearch = true;
export let onchange: ((event: CustomEvent) => void) | null = null;
let dialog: HTMLDialogElement | null = null;
let search = '';
let currentValue: string | null = null;
$: if (selectedValue !== undefined) {
currentValue = selectedValue ?? null;
}
$: selectedProvider = providers.find(
(provider) => provider?.[valueKey] === currentValue
);
$: filteredProviders = !search
? providers
: providers.filter((provider) => {
const name = provider?.name?.toLowerCase() || '';
const url = provider?.mainUrl?.toLowerCase() || '';
const lang = provider?.lang?.toLowerCase() || '';
const query = search.toLowerCase();
return name.includes(query) || url.includes(query) || lang.includes(query);
});
function openDialog() {
if (disabled) return;
search = '';
dialog?.showModal();
}
function closeDialog() {
dialog?.close();
}
function selectProvider(provider: any) {
const value = provider?.[valueKey];
if (!value) return;
currentValue = value;
const event = new CustomEvent('change', { detail: { value, provider } });
onchange?.(event);
closeDialog();
}
</script>
<button class={buttonClass} onclick={openDialog} disabled={disabled}>
<span class="flex items-center gap-2 truncate">
<span class="font-semibold truncate">
{selectedProvider?.name || title}
</span>
{#if selectedProvider?.lang}
<span class="badge badge-sm badge-neutral">{selectedProvider.lang}</span>
{/if}
</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 opacity-60" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
<dialog class="modal" bind:this={dialog}>
<div class="modal-box max-w-5xl">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-lg font-bold">{title}</h3>
{#if description}
<p class="text-sm opacity-60 mt-1">{description}</p>
{/if}
</div>
<button class="btn btn-sm btn-ghost" onclick={closeDialog}>Close</button>
</div>
{#if allowSearch}
<div class="mt-4">
<input
class="input input-bordered w-full"
placeholder="Search providers..."
bind:value={search}
/>
</div>
{/if}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-4 max-h-[60vh] overflow-y-auto pr-1">
{#each filteredProviders as provider}
<button
class="card bg-base-100 border border-base-content/10 hover:border-primary/60 text-left transition-colors"
onclick={() => selectProvider(provider)}
>
<div class="card-body p-4 gap-2">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h4 class="font-bold truncate">{provider.name}</h4>
<p class="text-xs opacity-60 truncate">{provider.mainUrl}</p>
</div>
{#if provider.lang}
<span class="badge badge-sm badge-outline">{provider.lang}</span>
{/if}
</div>
{#if provider.supportedTypes?.length}
<div class="flex flex-wrap gap-1">
{#each provider.supportedTypes.slice(0, 3) as type}
<span class="badge badge-xs badge-ghost">{type}</span>
{/each}
{#if provider.supportedTypes.length > 3}
<span class="badge badge-xs badge-ghost">+{provider.supportedTypes.length - 3}</span>
{/if}
</div>
{/if}
</div>
</button>
{/each}
{#if filteredProviders.length === 0}
<div class="col-span-full py-10 text-center text-sm opacity-60">
No providers found.
</div>
{/if}
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>

13
server/ui/src/main.ts Normal file
View file

@ -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

View file

@ -0,0 +1,374 @@
<script lang="ts">
import { push, querystring } from 'svelte-spa-router';
import { api } from '../api';
import PosterCard from '../components/shared/PosterCard.svelte';
type EpisodeItem = {
season: number;
episode: number;
name: string;
data: string;
posterUrl?: string;
dub?: string;
description?: string;
date?: number;
runTime?: number;
rating?: number;
score?: { data: number };
};
let provider = '';
let mediaUrl = '';
let queryName = '';
let queryPoster = '';
let queryType = '';
let loading = false;
let error: string | null = null;
let details: any = null;
let episodes: EpisodeItem[] = [];
let seasons: number[] = [];
let selectedSeason: number | null = null;
let loadToken = 0;
let currentKey = '';
$: if ($querystring !== undefined) parseQuery($querystring || '');
$: episodes = normalizeEpisodes(details);
$: seasons = [...new Set(episodes.map((e) => e.season))].sort((a, b) => a - b);
$: if (seasons.length > 0 && (selectedSeason === null || !seasons.includes(selectedSeason))) {
selectedSeason = seasons[0];
}
$: if (seasons.length === 0) {
selectedSeason = null;
}
const seriesTypes = new Set(['TvSeries', 'Anime', 'OVA', 'Cartoon']);
$: isSeries = episodes.length > 0 || seriesTypes.has(details?.type);
function parseQuery(query: string) {
const params = new URLSearchParams(query);
const nextProvider = params.get('provider') || '';
const nextUrl = params.get('url') || '';
const nextName = params.get('name') || '';
const nextPoster = params.get('poster') || '';
const nextType = params.get('type') || '';
const key = `${nextProvider}::${nextUrl}`;
if (key === currentKey) return;
currentKey = key;
provider = nextProvider;
mediaUrl = nextUrl;
queryName = nextName;
queryPoster = nextPoster;
queryType = nextType;
loadDetails();
}
async function loadDetails() {
if (!provider || !mediaUrl) {
details = null;
episodes = [];
return;
}
const token = ++loadToken;
loading = true;
error = null;
try {
const data = await api.loadMedia(provider, mediaUrl);
if (token !== loadToken) return;
details = data;
} catch (e: any) {
if (token !== loadToken) return;
details = null;
error = e.message || 'Failed to load details';
} finally {
if (token === loadToken) loading = false;
}
}
function normalizeEpisodes(data: any): EpisodeItem[] {
if (!data?.episodes) return [];
if (Array.isArray(data.episodes)) {
return data.episodes.map((ep: any, index: number) => ({
season: ep.season ?? 1,
episode: ep.episode ?? index + 1,
name: ep.name || `Episode ${ep.episode ?? index + 1}`,
data: ep.data,
posterUrl: ep.posterUrl,
description: ep.description,
date: ep.date ?? ep.airDate ?? ep.air_date,
runTime: ep.runTime ?? ep.runtime ?? ep.duration,
rating: ep.rating,
score: ep.score,
}));
}
if (typeof data.episodes === 'object') {
return Object.entries(data.episodes).flatMap(([dub, list]) => {
if (!Array.isArray(list)) return [];
return list.map((ep: any, index: number) => ({
season: ep.season ?? 1,
episode: ep.episode ?? index + 1,
name: ep.name || `Episode ${ep.episode ?? index + 1}`,
data: ep.data,
posterUrl: ep.posterUrl,
description: ep.description,
date: ep.date ?? ep.airDate ?? ep.air_date,
runTime: ep.runTime ?? ep.runtime ?? ep.duration,
rating: ep.rating,
score: ep.score,
dub,
}));
});
}
return [];
}
function formatScore(value: number | undefined) {
if (value == null) return null;
return Math.round(value / 10000000) / 10;
}
function formatDate(ts: number | string | undefined) {
if (!ts) return '';
const n = typeof ts === 'string' ? Number(ts) : ts;
if (Number.isNaN(n)) return '';
const d = new Date(n);
return d.toLocaleDateString();
}
function formatRuntime(mins: number | undefined) {
if (!mins && mins !== 0) return null;
const h = Math.floor(mins / 60);
const m = mins % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function openRec(rec: any) {
const params = new URLSearchParams();
if (rec.apiName) params.set('provider', rec.apiName);
if (rec.url) params.set('url', rec.url);
if (rec.name) params.set('name', rec.name);
if (rec.posterUrl) params.set('poster', rec.posterUrl);
if (rec.type) params.set('type', rec.type);
push(`/details?${params.toString()}`);
}
function playMovie() {
const params = new URLSearchParams();
if (provider) params.set('provider', provider);
if (details?.name || queryName) params.set('name', details?.name || queryName);
if (details?.dataUrl || details?.data || details?.url) {
params.set('data', details?.dataUrl || details?.data || details?.url);
}
if (details?.posterUrl || queryPoster) params.set('poster', details?.posterUrl || queryPoster);
push(`/play?${params.toString()}`);
}
function playEpisode(ep: EpisodeItem) {
const params = new URLSearchParams();
if (provider) params.set('provider', provider);
if (details?.name || queryName) params.set('show', details?.name || queryName);
params.set('episode', ep.name);
params.set('data', ep.data);
if (ep.posterUrl || queryPoster) params.set('poster', ep.posterUrl || queryPoster);
push(`/play?${params.toString()}`);
}
function goBack() {
window.history.back();
}
</script>
<div class="min-h-full pb-20">
{#if loading}
<div class="flex items-center justify-center h-96">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if error}
<div class="p-10 flex flex-col items-center justify-center text-center">
<div class="text-error text-6xl mb-4">!</div>
<h3 class="text-xl font-bold mb-2">Failed to load details</h3>
<p class="text-base-content/60 max-w-md">{error}</p>
<button class="btn btn-primary mt-6" onclick={loadDetails}>Retry</button>
</div>
{: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}
<div class="relative w-full h-[55vh] overflow-hidden">
{#if heroImage}
<img src={heroImage} alt="" class="absolute inset-0 w-full h-full object-cover object-top opacity-70" />
{/if}
<div class="absolute inset-0 bg-gradient-to-t from-base-300 via-base-300/70 to-transparent"></div>
<div class="absolute top-6 left-6">
<button class="btn btn-sm btn-ghost" onclick={goBack}>Back</button>
</div>
<div class="absolute bottom-0 left-0 w-full p-8 md:p-12">
<div class="flex flex-col md:flex-row gap-6 items-end">
<div class="w-32 md:w-48 shrink-0">
{#if posterImage}
<img src={posterImage} alt={title} class="w-full rounded-xl shadow-xl border border-base-content/10" />
{:else}
<div class="w-full aspect-[2/3] rounded-xl bg-base-200"></div>
{/if}
</div>
<div class="flex-1">
<div class="flex flex-wrap gap-2 mb-3">
{#if typeLabel}
<span class="badge badge-primary">{typeLabel}</span>
{/if}
{#if details?.year}
<span class="badge badge-ghost">{details.year}</span>
{/if}
{#if details?.duration}
<span class="badge badge-ghost">{formatRuntime(details.duration)}</span>
{/if}
{#if details?.contentRating}
<span class="badge badge-ghost">{details.contentRating}</span>
{/if}
{#if details?.tags}
{#each details.tags as tag}
<span class="badge badge-ghost">{tag}</span>
{/each}
{/if}
{#if details?.score?.data}
<span class="badge badge-ghost">Score {Math.round(details.score.data / 10000000) / 10}</span>
{/if}
</div>
<h1 class="text-3xl md:text-5xl font-black">{title}</h1>
{#if details?.plot}
<p class="mt-4 text-base-content/70 max-w-2xl line-clamp-4">
{details.plot}
</p>
{/if}
<div class="mt-6 flex gap-3">
{#if !isSeries}
<button class="btn btn-primary" onclick={playMovie}>Play</button>
{/if}
</div>
</div>
</div>
</div>
</div>
<!-- recommendations moved below -->
{#if isSeries}
<div class="px-6 md:px-12 mt-8 grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-6">
<div class="space-y-4">
<h2 class="text-lg font-bold">Seasons</h2>
<div class="flex flex-col gap-2 overflow-y-auto max-h-[45vh] pr-2 seasons-scroll">
{#each seasons as season}
<button
class="btn btn-sm justify-start {selectedSeason === season ? 'btn-primary' : 'btn-ghost'}"
onclick={() => selectedSeason = season}
>
Season {season}
</button>
{/each}
{#if seasons.length === 0}
<div class="text-sm text-base-content/60">No seasons available.</div>
{/if}
</div>
</div>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold">Episodes</h2>
{#if selectedSeason !== null}
<span class="text-sm text-base-content/60">Season {selectedSeason}</span>
{/if}
</div>
<div class="grid gap-3 overflow-y-auto max-h-[45vh] episodes-scroll">
{#each episodes.filter((ep) => ep.season === selectedSeason) as ep}
<div class="card bg-base-100 border border-base-content/10">
<div class="card-body p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="flex items-center gap-4">
{#if ep.posterUrl}
<img src={ep.posterUrl} alt="" class="w-16 h-24 object-cover rounded-md" />
{:else}
<div class="w-16 h-24 rounded-md bg-base-200"></div>
{/if}
<div>
<div class="text-sm text-base-content/60">Episode {ep.episode}</div>
<div class="font-semibold">{ep.name}</div>
{#if ep.description}
<div class="text-sm text-base-content/70 mt-1 line-clamp-2">{ep.description}</div>
{/if}
<div class="flex flex-wrap gap-3 mt-2 text-xs text-base-content/60">
{#if ep.runTime}
<div>{formatRuntime(ep.runTime)}</div>
{/if}
{#if ep.rating}
<div>Rating {ep.rating}%</div>
{/if}
{#if ep.score?.data}
<div>Score {formatScore(ep.score.data)}</div>
{/if}
{#if ep.date}
<div>Aired {formatDate(ep.date)}</div>
{/if}
</div>
{#if ep.dub}
<div class="badge badge-xs badge-ghost mt-2">{ep.dub}</div>
{/if}
</div>
</div>
<button class="btn btn-sm btn-primary" onclick={() => playEpisode(ep)}>Play</button>
</div>
</div>
{/each}
{#if selectedSeason !== null && episodes.filter((ep) => ep.season === selectedSeason).length === 0}
<div class="text-sm text-base-content/60">No episodes found for this season.</div>
{/if}
</div>
</div>
</div>
{/if}
{#if details?.recommendations && details.recommendations.length > 0}
<div class="px-6 md:px-12 mt-8">
<h2 class="text-lg font-bold mb-3">Recommendations</h2>
<div class="relative group/carousel">
<div class="flex gap-4 overflow-x-auto pb-6 scroll-smooth snap-x">
{#each details.recommendations as rec}
<div class="w-[160px] md:w-[200px] shrink-0 snap-start">
<PosterCard
title={rec.name}
image={rec.posterUrl}
subtitle={rec.type}
onSelect={() => openRec(rec)}
/>
</div>
{/each}
</div>
</div>
</div>
{/if}
{:else}
<div class="p-20 text-center text-base-content/50">
Select a title to view details.
</div>
{/if}
</div>
<style>
/* Make scrollbars visible in WebKit and Firefox for the scrollable lists */
.seasons-scroll, .episodes-scroll {
scrollbar-width: auto; /* Firefox */
scrollbar-color: rgba(100,100,100,0.6) transparent;
}
.seasons-scroll::-webkit-scrollbar, .episodes-scroll::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.seasons-scroll::-webkit-scrollbar-track, .episodes-scroll::-webkit-scrollbar-track {
background: transparent;
}
.seasons-scroll::-webkit-scrollbar-thumb, .episodes-scroll::-webkit-scrollbar-thumb {
background-color: rgba(100,100,100,0.6);
border-radius: 9999px;
border: 3px solid transparent;
background-clip: padding-box;
}
</style>

View file

@ -0,0 +1,195 @@
<script lang="ts">
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router';
import { activeProvider, providers, loadInitialData } from '../stores';
import { api } from '../api';
import PosterCard from '../components/shared/PosterCard.svelte';
import ProviderPicker from '../components/shared/ProviderPicker.svelte';
let mainPageData: any[] | null = null;
let loading = false;
let error: string | null = null;
let loadToken = 0;
onMount(async () => {
if ($providers.length === 0) {
await loadInitialData();
}
});
// Reactive statement to load data when provider changes
$: if ($activeProvider) {
loadMainPage($activeProvider);
}
async function loadMainPage(provider: string) {
const token = ++loadToken;
loading = true;
error = null;
mainPageData = null;
try {
const pages = await api.getProviderMainPages(provider);
if (token !== loadToken) return;
const entries = (pages || []).filter((page: any) => page?.data);
if (entries.length === 0) {
mainPageData = [];
return;
}
const responses = await Promise.all(
entries.map(async (page: any) => {
if (!page?.data) return null;
try {
const response = await api.getProviderMainPage(provider, { data: page.data });
return { page, response };
} catch (err) {
console.error('Failed to load main page section', page?.name, err);
return null;
}
})
);
if (token !== loadToken) return;
mainPageData = responses.flatMap((entry) => {
if (!entry?.response?.items) return [];
return entry.response.items.map((row: any) => ({
...row,
name: entry.page?.name || row.name,
isHorizontalImages: entry.page?.horizontalImages ?? row.isHorizontalImages,
}));
});
} catch (e: any) {
if (token !== loadToken) return;
error = e.message;
mainPageData = [];
} finally {
if (token === loadToken) loading = false;
}
}
function handleProviderChange(event: CustomEvent) {
const value = event.detail?.value;
if (value) activeProvider.set(value);
}
function openDetails(item: any) {
if (!$activeProvider || !item?.url) return;
const params = new URLSearchParams({
provider: $activeProvider,
url: item.url
});
if (item.name) params.set('name', item.name);
if (item.posterUrl) params.set('poster', item.posterUrl);
if (item.type) params.set('type', item.type);
push(`/details?${params.toString()}`);
}
</script>
<div class="min-h-full pb-20">
<!-- Header / Controls -->
<div class="sticky top-0 z-30 bg-base-300/80 backdrop-blur-md px-6 py-4 flex items-center justify-between border-b border-white/5">
<h2 class="text-xl font-bold text-base-content">Browse</h2>
<ProviderPicker
providers={$providers}
selectedValue={$activeProvider}
valueKey="name"
title="Choose Provider"
description="Select which source to browse."
buttonClass="btn btn-sm btn-outline w-full max-w-xs justify-between"
onchange={handleProviderChange}
/>
</div>
{#if loading}
<div class="flex items-center justify-center h-96">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if error}
<div class="p-10 flex flex-col items-center justify-center text-center">
<div class="text-error text-6xl mb-4">⚠️</div>
<h3 class="text-xl font-bold mb-2">Failed to load content</h3>
<p class="text-base-content/60 max-w-md">{error}</p>
<button class="btn btn-primary mt-6" onclick={() => $activeProvider && loadMainPage($activeProvider)}>Retry</button>
</div>
{:else if mainPageData}
{#if mainPageData.length === 0}
<div class="p-20 text-center text-base-content/50">
No main page content available for this provider.
</div>
{:else}
<!-- Hero Section (Using first item of first row as Hero roughly) -->
{#if mainPageData.length > 0 && mainPageData[0]?.list?.length > 0}
{@const heroItem = mainPageData[0].list[0]}
<div class="relative w-full h-[60vh] overflow-hidden mb-8 group">
<div class="absolute inset-0">
<img src={heroItem.posterUrl} class="w-full h-full object-cover object-top opacity-60 mask-image-gradient" alt="Hero" />
<div class="absolute inset-0 bg-gradient-to-t from-base-300 via-base-300/50 to-transparent"></div>
</div>
<div class="absolute bottom-0 left-0 w-full p-8 md:p-12 flex flex-col items-start gap-4">
<div class="badge badge-primary font-bold">Featured</div>
<h1 class="text-4xl md:text-6xl font-black text-white drop-shadow-lg max-w-3xl leading-tight">
{heroItem.name}
</h1>
<p class="text-base-content/80 max-w-2xl text-lg line-clamp-3 md:line-clamp-2">
{heroItem.type} • Click to watch now
</p>
<div class="flex gap-3 mt-4">
<button class="btn btn-primary btn-lg gap-2 px-8" onclick={() => openDetails(heroItem)}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
Play Now
</button>
<button class="btn btn-neutral btn-lg gap-2" onclick={() => openDetails(heroItem)}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Details
</button>
</div>
</div>
</div>
{/if}
<!-- Content Rows -->
<div class="space-y-12 px-6 md:px-12">
{#each mainPageData as row}
{#if row.list && row.list.length > 0}
<section>
<h3 class="text-xl font-bold text-base-content mb-4 px-1 flex items-center gap-2">
<div class="w-1 h-6 bg-primary rounded-full"></div>
{row.name}
</h3>
<!-- Carousel container -->
<div class="relative group/carousel">
<div class="flex gap-4 overflow-x-auto pb-6 scroll-smooth snap-x">
{#each row.list as item}
<div class="w-[160px] md:w-[200px] shrink-0 snap-start">
<PosterCard
title={item.name}
image={item.posterUrl}
subtitle={item.type}
onSelect={() => openDetails(item)}
/>
</div>
{/each}
</div>
</div>
</section>
{/if}
{/each}
</div>
{/if}
{:else}
<div class="p-20 text-center text-base-content/50">
Select a provider to start browsing.
</div>
{/if}
</div>
<style>
.mask-image-gradient {
mask-image: linear-gradient(to bottom, black 50%, transparent 100%);
}
</style>

View file

@ -0,0 +1,219 @@
<script lang="ts">
import { querystring } from 'svelte-spa-router';
import { api, API_BASE_URL } from '../api';
type ExtractorLink = {
url: string;
referer: string;
quality: number;
name: string;
source: string;
isM3u8: boolean;
isDash: boolean;
allHeaders?: Record<string, string>;
userAgent?: string | null;
};
let provider = '';
let name = '';
let show = '';
let episode = '';
let data = '';
let poster = '';
let loading = false;
let error: string | null = null;
let links: ExtractorLink[] = [];
let subtitles: any[] = [];
let selectedLink: ExtractorLink | null = null;
let proxySrc = '';
let playerSrc: any = '';
let playerEl: any = null;
let loadToken = 0;
let currentKey = '';
$: if ($querystring !== undefined) parseQuery($querystring || '');
function parseQuery(query: string) {
const params = new URLSearchParams(query);
const nextProvider = params.get('provider') || '';
const nextData = params.get('data') || '';
const key = `${nextProvider}::${nextData}`;
provider = nextProvider;
data = nextData;
name = params.get('name') || '';
show = params.get('show') || '';
episode = params.get('episode') || '';
poster = params.get('poster') || '';
if (!provider || !data || key === currentKey) return;
currentKey = key;
loadLinks();
}
async function loadLinks() {
const token = ++loadToken;
loading = true;
error = null;
links = [];
subtitles = [];
selectedLink = null;
proxySrc = '';
try {
const response = await api.getProviderLinks(provider, data);
if (token !== loadToken) return;
if (!response?.links?.length) {
error = response?.error || 'No playable links found.';
return;
}
links = response.links;
subtitles = response.subtitles || [];
selectLink(getBestLink(links));
} catch (e: any) {
if (token !== loadToken) return;
error = e.message || 'Failed to load playback data.';
} finally {
if (token === loadToken) loading = false;
}
}
function getBestLink(items: ExtractorLink[]) {
return [...items].sort((a, b) => (b.quality || 0) - (a.quality || 0))[0] || null;
}
function selectLink(link: ExtractorLink | null) {
selectedLink = link;
proxySrc = link ? buildProxyUrl(link) : '';
playerSrc = link ? buildPlayerSource(link, proxySrc) : '';
}
$: if (playerEl) {
playerEl.src = playerSrc || '';
}
function buildPlayerSource(link: ExtractorLink, src: string) {
const type = inferMimeType(link);
return type ? { src, type } : src;
}
function buildProxyUrl(link: ExtractorLink) {
const params = new URLSearchParams();
params.set('url', link.url);
if (link.referer) params.set('referer', link.referer);
const headers = { ...(link.allHeaders || {}) };
if (link.userAgent && !headerExists(headers, 'User-Agent')) {
headers['User-Agent'] = link.userAgent;
}
const headersEncoded = encodeHeaders(headers);
if (headersEncoded) params.set('headers', headersEncoded);
return `${API_BASE_URL}/proxy?${params.toString()}`;
}
function encodeHeaders(headers: Record<string, string>) {
try {
const json = JSON.stringify(headers);
return btoa(unescape(encodeURIComponent(json)));
} catch {
return '';
}
}
function headerExists(headers: Record<string, string>, key: string) {
return Object.keys(headers).some((header) => header.toLowerCase() === key.toLowerCase());
}
function subtitleUrl(sub: any) {
const params = new URLSearchParams();
params.set('url', sub.url);
if (sub.headers) {
const headersEncoded = encodeHeaders(sub.headers);
if (headersEncoded) params.set('headers', headersEncoded);
}
return `${API_BASE_URL}/proxy?${params.toString()}`;
}
function inferMimeType(link: ExtractorLink) {
if (link.isDash) return 'application/dash+xml';
if (link.isM3u8) return 'application/x-mpegurl';
const url = link.url.toLowerCase();
if (url.includes('.mp4')) return 'video/mp4';
if (url.includes('.webm')) return 'video/webm';
if (url.includes('.mkv')) return 'video/x-matroska';
if (url.includes('.mov')) return 'video/quicktime';
if (url.includes('.mp3')) return 'audio/mpeg';
if (url.includes('.m4a')) return 'audio/mp4';
if (url.includes('.aac')) return 'audio/aac';
if (url.includes('.ogg') || url.includes('.ogv')) return 'video/ogg';
return 'video/mp4';
}
function goBack() {
window.history.back();
}
</script>
<div class="p-6 md:p-10 space-y-6">
<div class="flex items-center justify-between">
<button class="btn btn-sm btn-ghost" onclick={goBack}>Back</button>
{#if links.length > 1}
<select
class="select select-sm select-bordered"
onchange={(e) => {
const value = (e.target as HTMLSelectElement).value;
const next = links.find((link) => link.url === value) || null;
selectLink(next);
}}
>
{#each links as link}
<option value={link.url} selected={selectedLink?.url === link.url}>
{link.name || link.source}{link.quality}
</option>
{/each}
</select>
{/if}
</div>
<div>
<h1 class="text-2xl md:text-3xl font-bold">
{name || episode || show || 'Playback'}
</h1>
{#if show && episode}
<p class="text-base-content/60">{show}{episode}</p>
{:else if show}
<p class="text-base-content/60">{show}</p>
{/if}
</div>
{#if loading}
<div class="flex items-center justify-center h-80">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if error}
<div class="alert alert-error">
<span>{error}</span>
</div>
{:else if playerSrc}
<media-player
class="app-player w-full"
bind:this={playerEl}
title={name || show || 'Playback'}
poster={poster || undefined}
crossorigin
playsinline
>
<media-outlet>
{#each subtitles as sub}
<track
kind="subtitles"
src={subtitleUrl(sub)}
label={sub.lang || sub.langTag || 'Subtitle'}
srclang={sub.langTag || sub.lang || 'en'}
/>
{/each}
</media-outlet>
<media-community-skin></media-community-skin>
<media-poster></media-poster>
</media-player>
{:else}
<div class="text-base-content/60">Select a title to play.</div>
{/if}
</div>

View file

@ -0,0 +1,704 @@
<script lang="ts">
import { onMount } from 'svelte';
import { config as configStore, plugins, repositories, providers, loadInitialData } from '../stores';
import { toast } from '../stores/toast';
import { api } from '../api';
import ConfirmModal from '../components/shared/ConfirmModal.svelte';
import ProviderPicker from '../components/shared/ProviderPicker.svelte';
let activeTab = 'installed';
let loading = false;
let repoUrlInput = '';
let repoNameInput = '';
let providerOverrides: any[] = [];
let overrideBaseClass = '';
let overrideName = '';
let overrideUrl = '';
let overrideLang = '';
let overridesLoading = false;
onMount(async () => {
if (!$configStore) await loadInitialData();
await loadOverrides();
});
async function addRepository() {
if (!repoUrlInput) return;
loading = true;
try {
await api.addRepository(repoUrlInput, repoNameInput);
await loadInitialData(); // Reload to get new repos/plugins
repoUrlInput = '';
repoNameInput = '';
} catch (e) {
console.error(e);
toast.error('Failed to add repository');
} finally {
loading = false;
}
}
$: overrideableProviders = $providers.filter(
(provider) => provider.className && provider.canBeOverridden !== false
);
$: if (!overrideBaseClass && overrideableProviders.length > 0) {
overrideBaseClass = overrideableProviders[0].className;
}
let confirmRepoDelete: ConfirmModal;
async function removeRepository(id: string) {
if (!await confirmRepoDelete.show()) return;
loading = true;
try {
await api.removeRepository(id);
await loadInitialData();
} catch (e) {
console.error(e);
toast.error('Failed to remove repository');
} finally {
loading = false;
}
}
let confirmOverrideDelete: ConfirmModal;
async function loadOverrides() {
overridesLoading = true;
try {
providerOverrides = await api.getProviderOverrides();
} catch (e) {
console.error(e);
toast.error('Failed to load provider overrides');
} finally {
overridesLoading = false;
}
}
function resolveProviderLabel(parentClassName: string) {
const match = $providers.find((provider) => {
if (!provider.className) return false;
const simple = provider.className.split('.').pop();
return provider.className === parentClassName || simple === parentClassName;
});
return match ? match.name : parentClassName;
}
async function addOverride() {
if (!overrideBaseClass || !overrideName || !overrideUrl) return;
overridesLoading = true;
try {
await api.addProviderOverride({
parentClassName: overrideBaseClass,
name: overrideName.trim(),
url: overrideUrl.trim(),
lang: overrideLang.trim() || undefined,
});
overrideName = '';
overrideUrl = '';
overrideLang = '';
await loadOverrides();
await loadInitialData();
toast.success('Provider override added');
} catch (e) {
console.error(e);
toast.error('Failed to add provider override');
} finally {
overridesLoading = false;
}
}
async function removeOverride(overrideEntry: any) {
if (!await confirmOverrideDelete.show()) return;
overridesLoading = true;
try {
await api.removeProviderOverride(overrideEntry.name);
await loadOverrides();
await loadInitialData();
toast.success('Provider override removed');
} catch (e) {
console.error(e);
toast.error('Failed to remove provider override');
} finally {
overridesLoading = false;
}
}
// File upload handler
async function handleFileUpload(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files[0]) {
const file = input.files[0];
loading = true;
try {
await api.uploadPlugin(file);
await loadInitialData();
toast.success('Plugin uploaded successfully');
} catch (e) {
console.error(e);
toast.error('Failed to upload plugin');
} finally {
loading = false;
input.value = ''; // Reset input
}
}
}
// Repository Browsing
let browsingRepo: any = null;
let repoPluginsList: any[] = [];
// Filters & Search
let pluginSearch = '';
let selectedLang = '';
let selectedTvTypes: string[] = []; // Changed to array for multi-select
// Derived filters
$: uniqueLanguages = [...new Set(repoPluginsList.map(p => p.language).filter(Boolean))].sort();
$: uniqueTvTypes = [...new Set(repoPluginsList.flatMap(p => p.tvTypes || []).filter(Boolean))].sort();
$: filteredRepoPlugins = repoPluginsList.filter(plugin => {
const matchesSearch = !pluginSearch ||
(plugin.name?.toLowerCase().includes(pluginSearch.toLowerCase()) ||
plugin.description?.toLowerCase().includes(pluginSearch.toLowerCase()) ||
plugin.authors?.some((a: string) => a.toLowerCase().includes(pluginSearch.toLowerCase())));
const matchesLang = !selectedLang || plugin.language === selectedLang;
// Filter: Check if ALL selected types are present in the plugin's types
const matchesType = selectedTvTypes.length === 0 ||
selectedTvTypes.every(t => plugin.tvTypes?.includes(t));
return matchesSearch && matchesLang && matchesType;
});
function toggleTvTypeFilter(type: string) {
if (selectedTvTypes.includes(type)) {
selectedTvTypes = selectedTvTypes.filter(t => t !== type);
} else {
selectedTvTypes = [...selectedTvTypes, type];
}
}
// Helper to check install status
function getInstalledPlugin(internalName: string) {
return $plugins.find(p => p.internalName === internalName);
}
function getPluginStatus(plugin: any, pluginsList: any[]) {
const installed = pluginsList.find(p => p.internalName === plugin.internalName);
if (!installed) return 'install';
if (installed.version < plugin.version) return 'update';
return 'installed';
}
async function browseRepo(repo: any) {
loading = true;
try {
const data = await api.getRepositoryPlugins(repo.id || repo.url);
browsingRepo = data.repository;
// Normalize tvTypes: some repos might return types as ["Movie, Anime"] instead of ["Movie", "Anime"]
repoPluginsList = data.plugins.map((p: any) => {
const plugin = p.plugin;
if (plugin.tvTypes && Array.isArray(plugin.tvTypes)) {
plugin.tvTypes = plugin.tvTypes
.flatMap((t: string) => t.split(','))
.map((t: string) => t.trim())
.filter(Boolean);
}
return plugin;
});
// Reset filters
pluginSearch = '';
selectedLang = '';
selectedTvTypes = [];
} catch (e) {
console.error(e);
toast.error('Failed to load plugins');
} finally {
loading = false;
}
}
let installingPlugins = new Set<string>();
async function installPluginFromRepo(internalName: string) {
if (!browsingRepo) return;
const repoId = browsingRepo.id;
loading = true; // Keep global loading for safety or remove if per-button is enough? Keeping it might block other actions which is good, but user wants to see the button spinning.
// Actually, if we use global loading=true, the whole UI might be blocked or show a global spinner if implemented that way.
// In this file, global `loading` variable is used to disable buttons or show overlay?
// Looking at line 8: `let loading = false;`
// And looking at the UI, `loading` is not used to mask the whole screen in the browsing view currently, but it is used in "Add Repo" button.
// Let's rely on local state for the button and keep global loading false or true?
// If I set loading=true, does it hide the grid? No, I don't see a "if loading" block wrapping the grid.
installingPlugins.add(internalName);
installingPlugins = installingPlugins; // Trigger reactivity
try {
await api.installRepositoryPlugin(repoId, internalName);
toast.success('Plugin installed!');
await loadInitialData();
} catch (e) {
console.error(e);
toast.error('Failed to install plugin');
} finally {
installingPlugins.delete(internalName);
installingPlugins = installingPlugins;
loading = false;
}
}
let confirmUninstall: ConfirmModal;
let pluginToUninstall: any = null;
function handleIconError(e: Event) {
const target = e.currentTarget as HTMLImageElement;
target.classList.add('hidden');
}
async function uninstallPlugin(plugin: any) {
pluginToUninstall = plugin;
if(!await confirmUninstall.show()) {
pluginToUninstall = null;
return;
}
loading = true;
try {
const installed = getInstalledPlugin(plugin.internalName);
if (installed) {
await api.removePlugin({ filePath: installed.filePath });
await loadInitialData();
toast.success('Plugin uninstalled');
}
} catch(e) {
console.error(e);
toast.error('Failed to uninstall');
} finally {
loading = false;
pluginToUninstall = null;
}
}
function closeBrowsing() {
browsingRepo = null;
repoPluginsList = [];
}
</script>
<div class="p-6 md:p-12 max-w-7xl mx-auto">
<div class="flex items-center justify-between mb-8">
{#if browsingRepo}
<div class="flex items-center gap-4">
<button class="btn btn-circle btn-ghost" onclick={closeBrowsing}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</button>
<div>
<h1 class="text-3xl font-bold text-base-content">{browsingRepo.name}</h1>
<p class="text-sm opacity-50">Browsing Repository</p>
</div>
</div>
{:else}
<h1 class="text-4xl font-bold text-base-content">Plugins</h1>
{/if}
{#if !browsingRepo}
<div class="join">
<button
class="join-item btn {activeTab === 'installed' ? 'btn-primary' : 'btn-neutral'}"
onclick={() => activeTab = 'installed'}>
Installed
</button>
<button
class="join-item btn {activeTab === 'repositories' ? 'btn-primary' : 'btn-neutral'}"
onclick={() => activeTab = 'repositories'}>
Repositories
</button>
<button
class="join-item btn {activeTab === 'overrides' ? 'btn-primary' : 'btn-neutral'}"
onclick={() => activeTab = 'overrides'}>
Overrides
</button>
</div>
{/if}
</div>
{#if browsingRepo}
<!-- Filters & Search -->
<div class="flex flex-col md:flex-row gap-4 mb-8">
<input
type="text"
placeholder="Search plugins..."
bind:value={pluginSearch}
class="input input-bordered w-full md:flex-1"
/>
<select class="select select-bordered w-full md:w-auto" bind:value={selectedLang}>
<option value="">All Languages</option>
{#each uniqueLanguages as lang}
<option value={lang}>{lang}</option>
{/each}
</select>
<!-- Multi-select TV Types Dropdown -->
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="select select-bordered w-full md:w-auto flex items-center justify-between px-3">
<span>Filter Types {selectedTvTypes.length > 0 ? `(${selectedTvTypes.length})` : ''}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 opacity-60 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
</div>
<ul tabindex="-1" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box min-w-[13rem] w-52 max-h-96 overflow-y-auto flex-nowrap block">
{#each uniqueTvTypes as type}
<li>
<label class="label cursor-pointer justify-start">
<input
type="checkbox"
class="checkbox checkbox-sm checkbox-primary"
checked={selectedTvTypes.includes(type)}
onchange={() => toggleTvTypeFilter(type)}
/>
<span class="label-text">{type}</span>
</label>
</li>
{/each}
{#if uniqueTvTypes.length === 0}
<li class="disabled"><a>No types found</a></li>
{/if}
</ul>
</div>
</div>
<!-- Browsing View -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{#each filteredRepoPlugins as plugin}
{@const status = getPluginStatus(plugin, $plugins)}
<div class="card bg-base-100 shadow-xl border border-base-content/5 flex flex-col h-full">
<div class="card-body p-5 flex flex-col h-full">
<div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-3 w-full overflow-hidden">
<div class="size-12 rounded-md bg-base-300 shrink-0 relative overflow-hidden flex items-center justify-center text-base-content/70">
<svg xmlns="http://www.w3.org/2000/svg" class="size-6 opacity-70" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/>
</svg>
{#if plugin.iconUrl}
<img
src={plugin.iconUrl.replace('%size%', '64')}
alt=""
class="absolute inset-0 w-full h-full object-contain bg-base-300"
onerror={handleIconError}
/>
{/if}
</div>
<div class="min-w-0">
<h3 class="font-bold truncate" title={plugin.name}>{plugin.name}</h3>
<p class="text-xs opacity-60 truncate">by {plugin.authors?.join(', ') || 'Unknown'}</p>
<div class="flex flex-wrap gap-1 mt-1">
{#if plugin.language}
<span class="badge badge-xs badge-neutral">{plugin.language}</span>
{/if}
{#if plugin.version}
<span class="badge badge-xs badge-ghost">v{plugin.version}</span>
{/if}
</div>
</div>
</div>
</div>
{#if plugin.tvTypes && plugin.tvTypes.length > 0}
<div class="flex flex-wrap gap-1 mt-3">
{#each plugin.tvTypes.slice(0, 3) as type}
<span class="badge badge-xs badge-outline opacity-70">{type}</span>
{/each}
{#if plugin.tvTypes.length > 3}
<span class="badge badge-xs badge-outline opacity-50">+{plugin.tvTypes.length - 3}</span>
{/if}
</div>
{/if}
<p class="text-sm mt-3 line-clamp-3 opacity-80 grow">
{plugin.description || 'No description provided.'}
</p>
<div class="card-actions justify-end mt-4 pt-4 border-t border-base-content/10">
{#if status === 'update'}
<button
class="btn btn-sm btn-info text-info-content"
onclick={() => installPluginFromRepo(plugin.internalName)}
disabled={installingPlugins.has(plugin.internalName)}
>
{#if installingPlugins.has(plugin.internalName)}
<span class="loading loading-spinner loading-xs"></span>
{/if}
Update
</button>
{:else if status === 'installed'}
<button class="btn btn-sm btn-error btn-outline" onclick={() => uninstallPlugin(plugin)}>
Uninstall
</button>
{:else}
<button
class="btn btn-sm btn-primary"
onclick={() => installPluginFromRepo(plugin.internalName)}
disabled={installingPlugins.has(plugin.internalName)}
>
{#if installingPlugins.has(plugin.internalName)}
<span class="loading loading-spinner loading-xs"></span>
{/if}
Install
</button>
{/if}
</div>
</div>
</div>
{/each}
{#if filteredRepoPlugins.length === 0}
<div class="col-span-full py-20 text-center opacity-50">
{#if repoPluginsList.length === 0}
No plugins found in this repository.
{:else}
No plugins match your filters.
{/if}
</div>
{/if}
</div>
{:else if activeTab === 'installed'}
<div class="space-y-8">
<!-- Upload Area (Small) -->
<div class="card bg-base-200 border-2 border-dashed border-base-content/20 hover:border-primary transition-colors">
<div class="card-body items-center text-center p-6">
<h3 class="font-bold text-lg">Install Local Plugin</h3>
<p class="text-sm opacity-60">Drag & drop .cs3 file or click to upload</p>
<label class="btn btn-sm btn-outline mt-2">
Choose File
<input type="file" accept=".cs3" class="hidden" onchange={handleFileUpload} />
</label>
</div>
</div>
<!-- Installed Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{#each $plugins as plugin}
<div class="card bg-base-100 shadow-xl border border-base-content/5">
<div class="card-body p-5">
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="size-10 rounded-md bg-base-300 shrink-0 relative overflow-hidden flex items-center justify-center text-base-content/70">
<svg xmlns="http://www.w3.org/2000/svg" class="size-5 opacity-70" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/>
</svg>
{#if plugin.iconUrl}
<img
src={plugin.iconUrl.replace('%size%', '64')}
alt=""
class="absolute inset-0 w-full h-full object-contain bg-base-300"
onerror={handleIconError}
/>
{/if}
</div>
<div>
<h3 class="font-bold">{plugin.name || plugin.internalName}</h3>
<p class="text-xs opacity-60">v{plugin.version}{plugin.authors?.join(', ')}</p>
</div>
</div>
<div class="flex items-center gap-2 text-xs opacity-70">
<span class="inline-block size-2 rounded-full {plugin.status === 1 ? 'bg-success' : 'bg-warning'}"></span>
<span>{plugin.status === 1 ? 'Working' : 'Issues'}</span>
</div>
</div>
<p class="text-sm mt-3 line-clamp-2 opacity-80 min-h-[2.5em]">
{plugin.description || 'No description provided.'}
</p>
<div class="card-actions justify-end mt-4 pt-4 border-t border-base-content/10">
<button class="btn btn-sm btn-ghost text-error" onclick={() => uninstallPlugin(plugin)}>
Uninstall
</button>
</div>
</div>
</div>
{/each}
{#if $plugins.length === 0}
<div class="col-span-full py-20 text-center opacity-50">
No plugins installed. Check Repositories to add some.
</div>
{/if}
</div>
</div>
{:else if activeTab === 'repositories'}
<div class="space-y-8">
<!-- Add Repo form -->
<div class="flex flex-col md:flex-row gap-4 bg-base-200 p-6 rounded-box items-end">
<div class="form-control w-full md:flex-1">
<label class="label"><span class="label-text">Repository URL</span></label>
<input type="text" bind:value={repoUrlInput} placeholder="https://..." class="input input-bordered w-full" />
</div>
<div class="form-control w-full md:w-64">
<label class="label"><span class="label-text">Name (Optional)</span></label>
<input type="text" bind:value={repoNameInput} placeholder="My Repo" class="input input-bordered w-full" />
</div>
<button class="btn btn-primary" onclick={addRepository} disabled={!repoUrlInput || loading}>
{loading ? 'Adding...' : 'Add Repository'}
</button>
</div>
<!-- Repo List -->
<div class="grid gap-4">
{#each $repositories as repo}
<div class="alert bg-base-100 shadow-sm border border-base-content/5 flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="size-8 rounded bg-base-300 shrink-0 relative overflow-hidden flex items-center justify-center text-base-content/70">
<svg xmlns="http://www.w3.org/2000/svg" class="size-4 opacity-70" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/>
</svg>
{#if repo.iconUrl}
<img
src={repo.iconUrl}
alt=""
class="absolute inset-0 w-full h-full object-contain bg-base-300"
onerror={handleIconError}
/>
{/if}
</div>
<div>
<h3 class="font-bold">{repo.name}</h3>
<div class="text-xs opacity-50 font-mono truncate max-w-md">{repo.url}</div>
{#if repo.description}
<div class="text-xs opacity-70 mt-1 line-clamp-2 max-w-md">{repo.description}</div>
{/if}
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-sm btn-outline" onclick={() => browseRepo(repo)}>
Browse Plugins
</button>
<button class="btn btn-sm btn-square btn-ghost text-error" onclick={() => removeRepository(repo.id)}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
</div>
{/each}
</div>
</div>
{:else if activeTab === 'overrides'}
<div class="space-y-8">
<div class="card bg-base-200 border border-base-content/10">
<div class="card-body gap-4">
<div>
<h3 class="font-bold text-lg">Add Provider Override</h3>
<p class="text-sm opacity-60">
Create a custom provider by overriding the base URL, name, or language.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text">Base Provider</span></label>
<ProviderPicker
providers={overrideableProviders}
selectedValue={overrideBaseClass}
valueKey="className"
title="Select Base Provider"
description="Choose the provider to clone."
buttonClass="btn btn-outline w-full justify-between"
onchange={(event) => (overrideBaseClass = event.detail.value)}
/>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Override Name</span></label>
<input
type="text"
bind:value={overrideName}
placeholder="My Custom Provider"
class="input input-bordered w-full"
/>
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text">Override URL</span></label>
<input
type="text"
bind:value={overrideUrl}
placeholder="https://example.com"
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Language</span></label>
<input
type="text"
bind:value={overrideLang}
placeholder="en"
class="input input-bordered w-full"
/>
<label class="label">
<span class="label-text-alt opacity-60">Optional, defaults to base provider.</span>
</label>
</div>
</div>
<div class="flex justify-end">
<button
class="btn btn-primary"
onclick={addOverride}
disabled={!overrideBaseClass || !overrideName || !overrideUrl || overridesLoading}
>
{overridesLoading ? 'Saving...' : 'Add Override'}
</button>
</div>
</div>
</div>
<div class="space-y-4">
<h3 class="text-lg font-bold">Current Overrides</h3>
{#if providerOverrides.length === 0}
<div class="py-12 text-center text-sm opacity-60">
No overrides added yet.
</div>
{:else}
<div class="grid gap-3">
{#each providerOverrides as overrideEntry}
<div class="alert bg-base-100 shadow-sm border border-base-content/5 flex items-center justify-between">
<div>
<h4 class="font-bold">{overrideEntry.name}</h4>
<div class="text-xs opacity-60">
Base: {resolveProviderLabel(overrideEntry.parentClassName)}
</div>
<div class="text-xs opacity-50 font-mono truncate max-w-md">{overrideEntry.url}</div>
<div class="text-xs opacity-50">Lang: {overrideEntry.lang}</div>
</div>
<button
class="btn btn-sm btn-ghost text-error"
onclick={() => removeOverride(overrideEntry)}
>
Remove
</button>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
<ConfirmModal
bind:this={confirmRepoDelete}
title="Remove Repository"
message="Are you sure you want to remove this repository? Plugins installed from it may no longer receive updates."
confirmText="Remove"
type="error"
/>
<ConfirmModal
bind:this={confirmUninstall}
title="Uninstall Plugin"
message={pluginToUninstall ? `Are you sure you want to uninstall ${pluginToUninstall.name}?` : 'Are you sure?'}
confirmText="Uninstall"
type="error"
/>
<ConfirmModal
bind:this={confirmOverrideDelete}
title="Remove Override"
message="Are you sure you want to remove this override?"
confirmText="Remove"
type="error"
/>
</div>

View file

@ -0,0 +1,150 @@
<script lang="ts">
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router';
import { activeProvider, providers, loadInitialData } from '../stores';
import { api } from '../api';
import PosterCard from '../components/shared/PosterCard.svelte';
import ProviderPicker from '../components/shared/ProviderPicker.svelte';
let query = '';
let searchResults: any[] = [];
let loading = false;
let error: string | null = null;
let selectedTvTypes: string[] = [];
// TvType options typically available (this list could be dynamic in a real app)
const tvTypes = ['Movie', 'TvSeries', 'Anime', 'Cartoon', 'Live', 'AsianDrama'];
onMount(async () => {
if ($providers.length === 0) {
await loadInitialData();
}
});
async function handleSearch() {
if (!query.trim() || !$activeProvider) return;
loading = true;
error = null;
searchResults = [];
try {
const data = await api.searchProvider($activeProvider, query);
// API returns { items: SearchResponse[], hasNext: boolean }
if (data?.items && Array.isArray(data.items)) {
searchResults = data.items;
} else if (Array.isArray(data)) {
searchResults = data;
}
if (selectedTvTypes.length > 0) {
searchResults = searchResults.filter(item => selectedTvTypes.includes(item.type));
}
} catch (e: any) {
error = e.message;
} finally {
loading = false;
}
}
function toggleTvType(type: string) {
if (selectedTvTypes.includes(type)) {
selectedTvTypes = selectedTvTypes.filter(t => t !== type);
} else {
selectedTvTypes = [...selectedTvTypes, type];
}
if (query) handleSearch();
}
function handleProviderChange(event: CustomEvent) {
const value = event.detail?.value;
if (value) activeProvider.set(value);
}
function openDetails(item: any) {
if (!$activeProvider || !item?.url) return;
const params = new URLSearchParams({
provider: $activeProvider,
url: item.url
});
if (item.name) params.set('name', item.name);
if (item.posterUrl) params.set('poster', item.posterUrl);
if (item.type) params.set('type', item.type);
push(`/details?${params.toString()}`);
}
</script>
<div class="p-6 md:p-12 max-w-7xl mx-auto space-y-8">
<div class="flex flex-col gap-6">
<h1 class="text-4xl font-bold text-base-content">Search</h1>
<!-- Search Bar & Provider Select -->
<div class="flex flex-col md:flex-row gap-4">
<div class="join w-full">
<ProviderPicker
providers={$providers}
selectedValue={$activeProvider}
valueKey="name"
title="Provider"
description="Select which source to search."
buttonClass="btn btn-outline join-item bg-base-200 justify-between"
onchange={handleProviderChange}
/>
<div class="relative w-full">
<input
type="text"
placeholder="Search for movies, shows, anime..."
class="input input-bordered join-item w-full bg-base-200 focus:bg-base-100 transition-colors"
bind:value={query}
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
/>
{#if loading}
<span class="loading loading-spinner loading-sm absolute right-4 top-1/2 -translate-y-1/2 text-primary"></span>
{/if}
</div>
<button class="btn btn-primary join-item" onclick={handleSearch} disabled={loading || !query}>
Search
</button>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-2">
{#each tvTypes as type}
<button
class="btn btn-sm rounded-full {selectedTvTypes.includes(type) ? 'btn-secondary text-base-content' : 'btn-outline border-base-content/20 text-base-content/60 hover:btn-ghost'}"
onclick={() => toggleTvType(type)}
>
{type}
</button>
{/each}
</div>
</div>
<!-- Results -->
<div class="min-h-[400px]">
{#if error}
<div class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<span>{error}</span>
</div>
{:else if searchResults.length === 0 && !loading && query}
<div class="text-center py-20 text-base-content/50">
No results found for "{query}".
</div>
{:else if searchResults.length > 0}
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 md:gap-6">
{#each searchResults as item}
<PosterCard
title={item.name}
image={item.posterUrl}
subtitle={item.type}
onSelect={() => openDetails(item)}
/>
{/each}
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,152 @@
<script lang="ts">
import { config as configStore, repositories, plugins, loadInitialData } from '../stores';
import { theme, themes } from '../stores/theme';
import { api } from '../api';
import { onMount } from 'svelte';
let activeTab = 'general';
let serverConfig: any = {};
let saveStatus = '';
// Subscribe to config store to initialize form
$: if ($configStore && $configStore.server) {
// Clone to avoid direct mutation
serverConfig = JSON.parse(JSON.stringify($configStore.server));
}
onMount(async () => {
if (!$configStore) await loadInitialData();
});
async function saveConfig() {
if (!$configStore) return;
saveStatus = 'saving';
try {
const updated = { ...$configStore, server: serverConfig };
await api.updateConfig(updated);
configStore.set(updated);
saveStatus = 'success';
setTimeout(() => saveStatus = '', 3000);
} catch (e) {
console.error(e);
saveStatus = 'error';
}
}
</script>
<div class="p-6 md:p-12 max-w-4xl mx-auto">
<h1 class="text-4xl font-bold text-base-content mb-8">Settings</h1>
<!-- Tabs -->
<div role="tablist" class="tabs tabs-lifted tabs-lg mb-8">
<a role="tab" class="tab {activeTab === 'general' ? 'tab-active' : ''}" onclick={() => activeTab = 'general'}>General</a>
<a role="tab" class="tab {activeTab === 'theme' ? 'tab-active' : ''}" onclick={() => activeTab = 'theme'}>Theme</a>
<a role="tab" class="tab {activeTab === 'accounts' ? 'tab-active' : ''}" onclick={() => activeTab = 'accounts'}>Accounts</a>
</div>
<div class="bg-base-200 rounded-box p-6 md:p-8 min-h-[400px]">
{#if activeTab === 'general'}
<div class="space-y-6 max-w-lg">
<h2 class="text-2xl font-bold mb-4">Server Configuration</h2>
<div class="form-control w-full">
<div class="label">
<span class="label-text">Host</span>
</div>
<input type="text" bind:value={serverConfig.host} class="input input-bordered w-full" />
</div>
<div class="form-control w-full">
<div class="label">
<span class="label-text">Port</span>
</div>
<input type="number" bind:value={serverConfig.port} class="input input-bordered w-full" />
</div>
<div class="form-control w-full">
<label class="label cursor-pointer justify-start gap-4">
<input type="checkbox" class="toggle toggle-primary" bind:checked={serverConfig.useJsdelivr} />
<span class="label-text">Use jsDelivr for repositories</span>
</label>
</div>
<div class="pt-4">
<button class="btn btn-primary" onclick={saveConfig} disabled={saveStatus === 'saving'}>
{#if saveStatus === 'saving'}
<span class="loading loading-spinner"></span>
{/if}
Save Changes
</button>
{#if saveStatus === 'success'}
<span class="text-success ml-4 fade-in">Saved!</span>
{/if}
{#if saveStatus === 'error'}
<span class="text-error ml-4">Failed to save.</span>
{/if}
</div>
</div>
{:else if activeTab === 'theme'}
<div class="space-y-6">
<h2 class="text-2xl font-bold mb-4 text-base-content">Appearance</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{#each themes as t}
<button
class="card bg-base-100 shadow-xl overflow-hidden border-2 transition-all hover:scale-105 {$theme === t ? 'border-primary ring-2 ring-primary ring-offset-2 ring-offset-base-100' : 'border-base-content/10'}"
onclick={() => theme.set(t)}
data-theme={t}
>
<div class="w-full h-24 bg-base-100 flex flex-col cursor-pointer">
<div class="flex-1 bg-base-200 w-full p-2 flex gap-1">
<div class="w-2 h-2 rounded-full bg-primary"></div>
<div class="w-2 h-2 rounded-full bg-secondary"></div>
<div class="w-2 h-2 rounded-full bg-accent"></div>
<div class="w-2 h-2 rounded-full bg-neutral"></div>
</div>
<div class="flex-1 bg-base-100 w-full flex items-center justify-center">
<span class="font-bold text-sm capitalize">{t}</span>
</div>
</div>
</button>
{/each}
</div>
</div>
{:else if activeTab === 'accounts'}
<div class="space-y-6">
<h2 class="text-2xl font-bold mb-4 text-base-content">Accounts</h2>
<div class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>Account management coming soon. Configuration is stored in `config.json`.</span>
</div>
<div class="overflow-x-auto">
<table class="table w-full">
<thead>
<tr>
<th>Type</th>
<th>Name</th>
<th>ID</th>
</tr>
</thead>
<tbody>
{#if $configStore && $configStore.accounts}
{#each $configStore.accounts as account}
<tr>
<td>{account.type}</td>
<td>{account.name || '-'}</td>
<td class="font-mono text-xs opacity-50">{account.id}</td>
</tr>
{/each}
{/if}
</tbody>
</table>
{#if !$configStore?.accounts?.length}
<p class="text-center py-4 text-base-content/50">No accounts configured.</p>
{/if}
</div>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,48 @@
import { writable } from 'svelte/store';
import { api } from '../api';
export const config = writable<any>(null);
export const providers = writable<any[]>([]);
export const plugins = writable<any[]>([]);
export const repositories = writable<any[]>([]);
export const activeProvider = writable<string | null>(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);
}
}

View file

@ -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();

View file

@ -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<Toast[]>([]);
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();

View file

@ -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(),
}

View file

@ -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"]
}

7
server/ui/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -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"]
}

23
server/ui/vite.config.ts Normal file
View file

@ -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/, ''),
},
},
},
})

View file

@ -18,4 +18,4 @@ dependencyResolutionManagement {
}
rootProject.name = "CloudStream"
include(":app", ":library", ":docs")
include(":app", ":library", ":docs", ":server")