mirror of
https://github.com/recloudstream/cloudstream.git
synced 2026-06-19 20:05:41 +00:00
Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
890b30c47d | ||
|
|
30df73645b |
59 changed files with 7795 additions and 1 deletions
|
|
@ -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)
|
jacksonModuleKotlin = { strictly = "2.13.1" } # Later versions don't support minSdk <26 (Crashes on Android TV's and FireSticks)
|
||||||
json = "20251224"
|
json = "20251224"
|
||||||
jsoup = "1.21.2"
|
jsoup = "1.21.2"
|
||||||
|
dex2jar = "2.4.34"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitKtx = "1.3.0"
|
junitKtx = "1.3.0"
|
||||||
junitVersion = "1.3.0"
|
junitVersion = "1.3.0"
|
||||||
juniversalchardet = "2.5.0"
|
juniversalchardet = "2.5.0"
|
||||||
kotlinGradlePlugin = "2.3.0"
|
kotlinGradlePlugin = "2.3.0"
|
||||||
kotlinxCoroutinesCore = "1.10.2"
|
kotlinxCoroutinesCore = "1.10.2"
|
||||||
|
ktor = "2.3.12"
|
||||||
lifecycleKtx = "2.9.4"
|
lifecycleKtx = "2.9.4"
|
||||||
|
logback = "1.5.13"
|
||||||
material = "1.14.0-alpha08"
|
material = "1.14.0-alpha08"
|
||||||
media3 = "1.8.0"
|
media3 = "1.8.0"
|
||||||
navigationKtx = "2.9.6"
|
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" }
|
fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragmentKtx" }
|
||||||
fuzzywuzzy = { module = "me.xdrop:fuzzywuzzy", version.ref = "fuzzywuzzy" }
|
fuzzywuzzy = { module = "me.xdrop:fuzzywuzzy", version.ref = "fuzzywuzzy" }
|
||||||
jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jacksonModuleKotlin" }
|
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" }
|
json = { module = "org.json:json", version.ref = "json" }
|
||||||
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
|
jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
|
||||||
junit = { module = "junit:junit", version.ref = "junit" }
|
junit = { module = "junit:junit", version.ref = "junit" }
|
||||||
junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "junitKtx" }
|
junit-ktx = { module = "androidx.test.ext:junit-ktx", version.ref = "junitKtx" }
|
||||||
juniversalchardet = { module = "com.github.albfernandez:juniversalchardet", version.ref = "juniversalchardet" }
|
juniversalchardet = { module = "com.github.albfernandez:juniversalchardet", version.ref = "juniversalchardet" }
|
||||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" }
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "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-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleKtx" }
|
||||||
lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-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" }
|
material = { module = "com.google.android.material:material", version.ref = "material" }
|
||||||
media3-cast = { module = "androidx.media3:media3-cast", version.ref = "media3" }
|
media3-cast = { module = "androidx.media3:media3-cast", version.ref = "media3" }
|
||||||
media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" }
|
media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" }
|
||||||
|
|
|
||||||
2
server/.gitignore
vendored
Normal file
2
server/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
data
|
||||||
|
config.json
|
||||||
59
server/build.gradle.kts
Normal file
59
server/build.gradle.kts
Normal 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
|
||||||
|
}
|
||||||
106
server/src/main/kotlin/android/content/Context.kt
Normal file
106
server/src/main/kotlin/android/content/Context.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
server/src/main/kotlin/android/content/SharedPreferences.kt
Normal file
32
server/src/main/kotlin/android/content/SharedPreferences.kt
Normal 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?)
|
||||||
|
}
|
||||||
|
}
|
||||||
3
server/src/main/kotlin/android/content/res/Resources.kt
Normal file
3
server/src/main/kotlin/android/content/res/Resources.kt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
package android.content.res
|
||||||
|
|
||||||
|
open class Resources
|
||||||
10
server/src/main/kotlin/android/net/Uri.kt
Normal file
10
server/src/main/kotlin/android/net/Uri.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
8
server/src/main/kotlin/android/util/Log.kt
Normal file
8
server/src/main/kotlin/android/util/Log.kt
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
281
server/src/main/kotlin/com/lagradost/cloudstream3/Models.kt
Normal file
281
server/src/main/kotlin/com/lagradost/cloudstream3/Models.kt
Normal 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",
|
||||||
|
)
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
1247
server/src/main/kotlin/com/lagradost/cloudstream3/Server.kt
Normal file
1247
server/src/main/kotlin/com/lagradost/cloudstream3/Server.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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?
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package com.lagradost.cloudstream3.syncproviders
|
||||||
|
|
||||||
|
abstract class SubtitleAPI : AuthAPI()
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package com.lagradost.cloudstream3.syncproviders
|
||||||
|
|
||||||
|
class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api)
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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()) }
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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
24
server/ui/.gitignore
vendored
Normal 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
47
server/ui/README.md
Normal 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
13
server/ui/index.html
Normal 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
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
28
server/ui/package.json
Normal 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
47
server/ui/src/App.svelte
Normal 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
156
server/ui/src/api/index.ts
Normal 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
52
server/ui/src/app.css
Normal 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;
|
||||||
|
}
|
||||||
8
server/ui/src/assets/poster-fallback.svg
Normal file
8
server/ui/src/assets/poster-fallback.svg
Normal 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 |
85
server/ui/src/components/layout/Sidebar.svelte
Normal file
85
server/ui/src/components/layout/Sidebar.svelte
Normal 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>
|
||||||
23
server/ui/src/components/layout/ToastContainer.svelte
Normal file
23
server/ui/src/components/layout/ToastContainer.svelte
Normal 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>
|
||||||
44
server/ui/src/components/shared/ConfirmModal.svelte
Normal file
44
server/ui/src/components/shared/ConfirmModal.svelte
Normal 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>
|
||||||
50
server/ui/src/components/shared/PosterCard.svelte
Normal file
50
server/ui/src/components/shared/PosterCard.svelte
Normal 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>
|
||||||
128
server/ui/src/components/shared/ProviderPicker.svelte
Normal file
128
server/ui/src/components/shared/ProviderPicker.svelte
Normal 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
13
server/ui/src/main.ts
Normal 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
|
||||||
374
server/ui/src/pages/Details.svelte
Normal file
374
server/ui/src/pages/Details.svelte
Normal 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>
|
||||||
195
server/ui/src/pages/Home.svelte
Normal file
195
server/ui/src/pages/Home.svelte
Normal 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>
|
||||||
219
server/ui/src/pages/Play.svelte
Normal file
219
server/ui/src/pages/Play.svelte
Normal 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>
|
||||||
704
server/ui/src/pages/PluginManager.svelte
Normal file
704
server/ui/src/pages/PluginManager.svelte
Normal 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>
|
||||||
150
server/ui/src/pages/Search.svelte
Normal file
150
server/ui/src/pages/Search.svelte
Normal 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>
|
||||||
152
server/ui/src/pages/Settings.svelte
Normal file
152
server/ui/src/pages/Settings.svelte
Normal 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>
|
||||||
48
server/ui/src/stores/index.ts
Normal file
48
server/ui/src/stores/index.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
server/ui/src/stores/theme.ts
Normal file
43
server/ui/src/stores/theme.ts
Normal 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();
|
||||||
38
server/ui/src/stores/toast.ts
Normal file
38
server/ui/src/stores/toast.ts
Normal 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();
|
||||||
8
server/ui/svelte.config.js
Normal file
8
server/ui/svelte.config.js
Normal 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(),
|
||||||
|
}
|
||||||
21
server/ui/tsconfig.app.json
Normal file
21
server/ui/tsconfig.app.json
Normal 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
7
server/ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
server/ui/tsconfig.node.json
Normal file
26
server/ui/tsconfig.node.json
Normal 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
23
server/ui/vite.config.ts
Normal 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/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -18,4 +18,4 @@ dependencyResolutionManagement {
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "CloudStream"
|
rootProject.name = "CloudStream"
|
||||||
include(":app", ":library", ":docs")
|
include(":app", ":library", ":docs", ":server")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue