mirror of
https://github.com/recloudstream/cloudstream.git
synced 2026-03-13 15:19:43 +08:00
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
This commit is contained in:
@@ -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<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
|
||||
@@ -90,6 +92,8 @@ data class SitePlugin(
|
||||
data class ExtractorRequest(
|
||||
val url: String,
|
||||
val referer: String? = null,
|
||||
val headers: Map<String, String>? = 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<String>,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<ExtractorRequest>()
|
||||
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<ProviderOverrideRequest>()
|
||||
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<ProviderRegisterRequest>()
|
||||
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<ExtractorLink>()
|
||||
val subtitles = mutableListOf<SubtitleFile>()
|
||||
val links = mutableListOf<ExtractorLink?>()
|
||||
val subtitles = mutableListOf<SubtitleFile?>()
|
||||
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<List<ExtractorLink>, String?> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val links = mutableListOf<ExtractorLink>()
|
||||
val links = mutableListOf<ExtractorLink?>()
|
||||
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<ExtractorLink>, 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<String, String>,
|
||||
userAgent: String?
|
||||
): Map<String, String> {
|
||||
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<String, String> {
|
||||
if (encoded.isNullOrBlank()) return emptyMap()
|
||||
return runCatching {
|
||||
val decoded = String(java.util.Base64.getDecoder().decode(encoded))
|
||||
mapper.readValue<Map<String, String>>(decoded)
|
||||
}.getOrElse { emptyMap() }
|
||||
}
|
||||
|
||||
private suspend fun proxyUrl(
|
||||
call: io.ktor.server.application.ApplicationCall,
|
||||
url: String,
|
||||
requestHeaders: Map<String, String>,
|
||||
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<String, String>): Map<String, String> {
|
||||
return headers.filterKeys { key ->
|
||||
!key.equals("Host", ignoreCase = true) &&
|
||||
!key.equals("Range", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun encodeHeadersParam(headers: Map<String, String>): 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<String, String>,
|
||||
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<String, String>,
|
||||
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<String, String>,
|
||||
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<ExtractorLink>()
|
||||
val subtitles = mutableListOf<SubtitleFile>()
|
||||
val links = mutableListOf<ExtractorLink?>()
|
||||
val subtitles = mutableListOf<SubtitleFile?>()
|
||||
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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
57
server/ui/package-lock.json
generated
57
server/ui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
@@ -75,11 +78,11 @@ export class CloudstreamAPI {
|
||||
});
|
||||
}
|
||||
|
||||
async removePlugin(internalName: string): Promise<any> {
|
||||
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({ internalName }),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,8 +100,39 @@ export class CloudstreamAPI {
|
||||
return this.request('/providers');
|
||||
}
|
||||
|
||||
async getProviderMainPage(providerName: string): Promise<any> {
|
||||
return this.request(`/providers/${providerName}/main-page`);
|
||||
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> {
|
||||
@@ -109,6 +143,14 @@ export class CloudstreamAPI {
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
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 |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,5 +1,10 @@
|
||||
<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' },
|
||||
@@ -11,13 +16,36 @@
|
||||
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-8 flex items-center justify-center shrink-0 text-primary">
|
||||
<svg viewBox="0 0 108 108" class="size-full fill-current">
|
||||
<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" />
|
||||
@@ -25,7 +53,10 @@
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="ml-3 font-bold text-lg hidden md:block">CloudStream</span>
|
||||
<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 -->
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
<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) {
|
||||
(e.target as HTMLImageElement).src = 'https://via.placeholder.com/300x450?text=No+Image';
|
||||
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">
|
||||
<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}
|
||||
|
||||
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>
|
||||
@@ -1,6 +1,10 @@
|
||||
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')!,
|
||||
|
||||
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>
|
||||
@@ -1,12 +1,15 @@
|
||||
<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;
|
||||
let mainPageData: any[] | null = null;
|
||||
let loading = false;
|
||||
let error: string | null = null;
|
||||
let loadToken = 0;
|
||||
|
||||
onMount(async () => {
|
||||
if ($providers.length === 0) {
|
||||
@@ -20,23 +23,63 @@
|
||||
}
|
||||
|
||||
async function loadMainPage(provider: string) {
|
||||
const token = ++loadToken;
|
||||
loading = true;
|
||||
error = null;
|
||||
mainPageData = null;
|
||||
try {
|
||||
const response = await api.getProviderMainPage(provider);
|
||||
// API returns { items: HomePageList[], hasNext: boolean }
|
||||
mainPageData = response.items || [];
|
||||
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 {
|
||||
loading = false;
|
||||
if (token === loadToken) loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleProviderChange(e: Event) {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
activeProvider.set(target.value);
|
||||
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>
|
||||
|
||||
@@ -45,11 +88,15 @@
|
||||
<!-- 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>
|
||||
<select class="select select-sm select-bordered w-full max-w-xs" onchange={handleProviderChange} value={$activeProvider}>
|
||||
{#each $providers as provider}
|
||||
<option value={provider.name}>{provider.name} ({provider.lang})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<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}
|
||||
@@ -64,71 +111,76 @@
|
||||
<button class="btn btn-primary mt-6" onclick={() => $activeProvider && loadMainPage($activeProvider)}>Retry</button>
|
||||
</div>
|
||||
{:else if mainPageData}
|
||||
|
||||
<!-- 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">
|
||||
<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">
|
||||
<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>
|
||||
{#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>
|
||||
</div>
|
||||
{/if}
|
||||
{/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 no-scrollbar">
|
||||
{#each row.list as item}
|
||||
<div class="w-[160px] md:w-[200px] shrink-0 snap-start">
|
||||
<!-- 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>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="p-20 text-center text-base-content/50">
|
||||
Select a provider to start browsing.
|
||||
|
||||
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>
|
||||
@@ -1,17 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { config as configStore, plugins, repositories, loadInitialData } from '../stores';
|
||||
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() {
|
||||
@@ -30,6 +38,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: 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) {
|
||||
@@ -46,6 +61,68 @@
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -175,6 +252,11 @@
|
||||
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()) {
|
||||
@@ -233,6 +315,11 @@
|
||||
onclick={() => activeTab = 'repositories'}>
|
||||
Repositories
|
||||
</button>
|
||||
<button
|
||||
class="join-item btn {activeTab === 'overrides' ? 'btn-primary' : 'btn-neutral'}"
|
||||
onclick={() => activeTab = 'overrides'}>
|
||||
Overrides
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -255,11 +342,11 @@
|
||||
|
||||
<!-- Multi-select TV Types Dropdown -->
|
||||
<div class="dropdown dropdown-end">
|
||||
<div tabindex="0" role="button" class="btn btn-outline m-1">
|
||||
Filter Types {selectedTvTypes.length > 0 ? `(${selectedTvTypes.length})` : ''}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" 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 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 w-52 max-h-96 overflow-y-auto flex-nowrap block">
|
||||
<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">
|
||||
@@ -288,19 +375,19 @@
|
||||
<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">
|
||||
{#if plugin.iconUrl}
|
||||
<img
|
||||
src={plugin.iconUrl.replace('%size%', '64')}
|
||||
alt=""
|
||||
class="size-12 rounded-md object-contain bg-base-300 shrink-0"
|
||||
onerror={(e) => e.currentTarget.style.display = 'none'}
|
||||
/>
|
||||
{:else}
|
||||
<div class="size-12 rounded-md bg-secondary/20 flex items-center justify-center text-secondary-content shrink-0">
|
||||
<!-- Monochrome Puzzle Icon Fallback -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="size-6 opacity-70 fill-current" viewBox="0 0 24 24"><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>
|
||||
</div>
|
||||
{/if}
|
||||
<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>
|
||||
@@ -395,20 +482,27 @@
|
||||
<div class="card-body p-5">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if plugin.iconUrl}
|
||||
<img src={plugin.iconUrl.replace('%size%', '64')} alt="" class="size-10 rounded-md object-contain bg-base-300" />
|
||||
{:else}
|
||||
<div class="size-10 rounded-md bg-primary flex items-center justify-center text-primary-content font-bold">
|
||||
{plugin.name?.charAt(0) || '?'}
|
||||
</div>
|
||||
{/if}
|
||||
<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="badge badge-sm {plugin.status === 1 ? 'badge-success' : 'badge-warning'}">
|
||||
{plugin.status === 1 ? 'Working' : 'Issues'}
|
||||
<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>
|
||||
|
||||
@@ -453,14 +547,25 @@
|
||||
{#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">
|
||||
{#if repo.iconUrl}
|
||||
<img src={repo.iconUrl} alt="" class="size-8 rounded object-contain" />
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6 opacity-50" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" /></svg>
|
||||
{/if}
|
||||
<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">
|
||||
@@ -475,6 +580,102 @@
|
||||
{/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
|
||||
@@ -492,4 +693,12 @@
|
||||
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>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<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[] = [];
|
||||
@@ -53,6 +55,23 @@
|
||||
}
|
||||
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">
|
||||
@@ -63,12 +82,15 @@
|
||||
<!-- Search Bar & Provider Select -->
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="join w-full">
|
||||
<select class="select select-bordered join-item bg-base-200" bind:value={$activeProvider}>
|
||||
<option disabled selected value={null}>Provider</option>
|
||||
{#each $providers as provider}
|
||||
<option value={provider.name}>{provider.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<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"
|
||||
@@ -117,7 +139,8 @@
|
||||
<PosterCard
|
||||
title={item.name}
|
||||
image={item.posterUrl}
|
||||
subtitle={item.type}
|
||||
subtitle={item.type}
|
||||
onSelect={() => openDetails(item)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,15 @@ 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 {
|
||||
@@ -23,8 +32,15 @@ export async function loadInitialData() {
|
||||
repositories.set(repos);
|
||||
|
||||
if (provs.length > 0) {
|
||||
// Set first provider as active if none selected (logic could be improved)
|
||||
activeProvider.update(current => current || provs[0].name);
|
||||
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);
|
||||
|
||||
@@ -6,15 +6,16 @@ const DEFAULT_THEME = 'forest';
|
||||
export const themes = [
|
||||
'forest',
|
||||
'dracula',
|
||||
'dim',
|
||||
'black',
|
||||
'sunset',
|
||||
'autumn',
|
||||
'synthwave',
|
||||
'pastel',
|
||||
'retro',
|
||||
'nord',
|
||||
'coffee',
|
||||
'night',
|
||||
'lemonade'
|
||||
'lemonade',
|
||||
'aqua'
|
||||
];
|
||||
|
||||
function createThemeStore() {
|
||||
|
||||
@@ -8,6 +8,9 @@ export default defineConfig({
|
||||
svelte(),
|
||||
tailwindcss()
|
||||
],
|
||||
optimizeDeps: {
|
||||
exclude: ['vidstack'],
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
Reference in New Issue
Block a user