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