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