forked from recloudstream/cloudstream
Revamped backend and added auto updating
This commit is contained in:
parent
8f3176d2cf
commit
89936c2bd6
9 changed files with 216 additions and 111 deletions
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
package="com.lagradost.cloudstream3">
|
package="com.lagradost.cloudstream3">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <!-- I dont remember, probs has to do with downloads -->
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <!-- I dont remember, probs has to do with downloads -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" /> <!-- unless you only use cs3 as a player for downloaded stuff, you need this -->
|
<uses-permission android:name="android.permission.INTERNET" /> <!-- unless you only use cs3 as a player for downloaded stuff, you need this -->
|
||||||
|
@ -12,41 +12,41 @@
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <!-- Used for player vertical slide-->
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <!-- Used for player vertical slide-->
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <!--Used for app update-->
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <!--Used for app update-->
|
||||||
<!--<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> not used atm, but code exist that requires it that are not run-->
|
<!--<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> not used atm, but code exist that requires it that are not run-->
|
||||||
<!-- <permission android:name="android.permission.QUERY_ALL_PACKAGES" /> <!– Used for getting if vlc is installed –>-->
|
<!-- <permission android:name="android.permission.QUERY_ALL_PACKAGES" /> <!– Used for getting if vlc is installed –>-->
|
||||||
|
|
||||||
<!-- Fixes android tv fuckery -->
|
<!-- Fixes android tv fuckery -->
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.touchscreen"
|
android:name="android.hardware.touchscreen"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.software.leanback"
|
android:name="android.software.leanback"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
|
||||||
<!--TODO https://stackoverflow.com/questions/41799732/chromecast-button-not-visible-in-android-->
|
<!--TODO https://stackoverflow.com/questions/41799732/chromecast-button-not-visible-in-android-->
|
||||||
<application
|
<application
|
||||||
android:name=".AcraApplication"
|
android:name=".AcraApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:appCategory="video"
|
||||||
android:banner="@mipmap/ic_banner"
|
android:banner="@mipmap/ic_banner"
|
||||||
android:label="@string/app_name"
|
android:fullBackupContent="@xml/backup_descriptor"
|
||||||
android:usesCleartextTraffic="true"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:theme="@style/AppTheme"
|
android:supportsRtl="true"
|
||||||
android:fullBackupContent="@xml/backup_descriptor"
|
android:theme="@style/AppTheme"
|
||||||
android:appCategory="video"
|
android:usesCleartextTraffic="true"
|
||||||
tools:targetApi="o">
|
tools:targetApi="o">
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||||
android:value="com.lagradost.cloudstream3.utils.CastOptionsProvider" />
|
android:value="com.lagradost.cloudstream3.utils.CastOptionsProvider" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.player.DownloadedPlayerActivity"
|
android:name=".ui.player.DownloadedPlayerActivity"
|
||||||
android:screenOrientation="userLandscape"
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
android:exported="true"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
android:supportsPictureInPicture="true"
|
android:screenOrientation="userLandscape"
|
||||||
android:exported="true">
|
android:supportsPictureInPicture="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
@ -57,18 +57,18 @@
|
||||||
|
|
||||||
<!-- I dont think this label can be translated, but idk -->
|
<!-- I dont think this label can be translated, but idk -->
|
||||||
<intent-filter android:label="@string/play_with_app_name">
|
<intent-filter android:label="@string/play_with_app_name">
|
||||||
<action android:name="android.intent.action.SEND"/>
|
<action android:name="android.intent.action.SEND" />
|
||||||
<category android:name="android.intent.category.DEFAULT"/>
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="*/*"/>
|
<data android:mimeType="*/*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:exported="true"
|
android:name=".MainActivity"
|
||||||
android:name=".MainActivity"
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
android:exported="true"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
android:supportsPictureInPicture="true">
|
android:supportsPictureInPicture="true">
|
||||||
<intent-filter android:exported="true">
|
<intent-filter android:exported="true">
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
@ -83,35 +83,39 @@
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data android:scheme="cloudstreamapp" />
|
<data android:scheme="cloudstreamapp" />
|
||||||
|
<data
|
||||||
|
android:host="cs.repo"
|
||||||
|
android:pathPrefix="/"
|
||||||
|
android:scheme="https" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".receivers.VideoDownloadRestartReceiver"
|
android:name=".receivers.VideoDownloadRestartReceiver"
|
||||||
android:enabled="false"
|
android:enabled="false"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter android:exported="true">
|
<intent-filter android:exported="true">
|
||||||
<action android:name="restart_service" />
|
<action android:name="restart_service" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
<service
|
<service
|
||||||
android:name=".services.VideoDownloadService"
|
android:name=".services.VideoDownloadService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:exported="false"
|
android:name=".ui.ControllerActivity"
|
||||||
android:name=".ui.ControllerActivity" />
|
android:exported="false" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.provider"
|
android:authorities="${applicationId}.provider"
|
||||||
android:exported="false"
|
android:enabled="true"
|
||||||
android:enabled="true"
|
android:exported="false"
|
||||||
android:grantUriPermissions="true">
|
android:grantUriPermissions="true">
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/provider_paths" />
|
android:resource="@xml/provider_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
|
@ -420,6 +420,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
app.initClient(this)
|
app.initClient(this)
|
||||||
|
|
||||||
|
PluginManager.updateAllOnlinePlugins(applicationContext)
|
||||||
PluginManager.loadAllLocalPlugins(applicationContext)
|
PluginManager.loadAllLocalPlugins(applicationContext)
|
||||||
PluginManager.loadAllOnlinePlugins(applicationContext)
|
PluginManager.loadAllOnlinePlugins(applicationContext)
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,7 @@ abstract class Plugin {
|
||||||
class Manifest {
|
class Manifest {
|
||||||
var name: String? = null
|
var name: String? = null
|
||||||
var pluginClassName: String? = null
|
var pluginClassName: String? = null
|
||||||
|
var pluginVersion: Int? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
var resources: Resources? = null
|
var resources: Resources? = null
|
||||||
|
|
|
@ -17,6 +17,11 @@ import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile
|
||||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
|
import com.lagradost.cloudstream3.apmap
|
||||||
|
import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
|
||||||
|
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
||||||
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager.sanitizeFilename
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
@ -27,13 +32,22 @@ import java.util.*
|
||||||
const val PLUGINS_KEY = "PLUGINS_KEY"
|
const val PLUGINS_KEY = "PLUGINS_KEY"
|
||||||
const val PLUGINS_KEY_LOCAL = "PLUGINS_KEY_LOCAL"
|
const val PLUGINS_KEY_LOCAL = "PLUGINS_KEY_LOCAL"
|
||||||
|
|
||||||
|
|
||||||
|
// Data class for internal storage
|
||||||
data class PluginData(
|
data class PluginData(
|
||||||
@JsonProperty("name") val name: String,
|
@JsonProperty("internalName") val internalName: String,
|
||||||
@JsonProperty("url") val url: String?,
|
@JsonProperty("url") val url: String?,
|
||||||
@JsonProperty("isOnline") val isOnline: Boolean,
|
@JsonProperty("isOnline") val isOnline: Boolean,
|
||||||
@JsonProperty("filePath") val filePath: String,
|
@JsonProperty("filePath") val filePath: String,
|
||||||
|
@JsonProperty("version") val version: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// This is used as a placeholder / not set version
|
||||||
|
const val PLUGIN_VERSION_NOT_SET = Int.MIN_VALUE
|
||||||
|
|
||||||
|
// This always updates
|
||||||
|
const val PLUGIN_VERSION_ALWAYS_UPDATE = -1
|
||||||
|
|
||||||
object PluginManager {
|
object PluginManager {
|
||||||
// Prevent multiple writes at once
|
// Prevent multiple writes at once
|
||||||
val lock = Mutex()
|
val lock = Mutex()
|
||||||
|
@ -80,22 +94,62 @@ object PluginManager {
|
||||||
private val LOCAL_PLUGINS_PATH =
|
private val LOCAL_PLUGINS_PATH =
|
||||||
Environment.getExternalStorageDirectory().absolutePath + "/Cloudstream3/plugins"
|
Environment.getExternalStorageDirectory().absolutePath + "/Cloudstream3/plugins"
|
||||||
|
|
||||||
|
// Maps filepath to plugin
|
||||||
private val plugins: MutableMap<String, Plugin> =
|
private val plugins: MutableMap<String, Plugin> =
|
||||||
LinkedHashMap<String, Plugin>()
|
LinkedHashMap<String, Plugin>()
|
||||||
|
|
||||||
private val classLoaders: MutableMap<PathClassLoader, Plugin> =
|
private val classLoaders: MutableMap<PathClassLoader, Plugin> =
|
||||||
HashMap<PathClassLoader, Plugin>()
|
HashMap<PathClassLoader, Plugin>()
|
||||||
|
|
||||||
private val failedToLoad: MutableMap<File, Any> = LinkedHashMap()
|
private val failedToLoad: MutableMap<File, Any> = LinkedHashMap()
|
||||||
var loadedLocalPlugins = false
|
private var loadedLocalPlugins = false
|
||||||
private val gson = Gson()
|
private val gson = Gson()
|
||||||
|
|
||||||
private fun maybeLoadPlugin(context: Context, file: File) {
|
private fun maybeLoadPlugin(context: Context, file: File) {
|
||||||
val name = file.name
|
val name = file.name
|
||||||
if (file.extension == "zip" || file.extension == "cs3") {
|
if (file.extension == "zip" || file.extension == "cs3") {
|
||||||
loadPlugin(context, file, PluginData(name, null, false, file.absolutePath))
|
loadPlugin(
|
||||||
|
context,
|
||||||
|
file,
|
||||||
|
PluginData(name, null, false, file.absolutePath, PLUGIN_VERSION_NOT_SET)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Needs to be run before other plugin loading because plugin loading can not be overwritten
|
||||||
|
**/
|
||||||
|
fun updateAllOnlinePlugins(context: Context) {
|
||||||
|
val urls = getKey<Array<RepositoryData>>(REPOSITORIES_KEY) ?: emptyArray()
|
||||||
|
|
||||||
|
val onlinePlugins = urls.toList().apmap {
|
||||||
|
getRepoPlugins(it.url)?.toList() ?: emptyList()
|
||||||
|
}.flatten()
|
||||||
|
|
||||||
|
// Iterates over all offline plugins, compares to remote repo and returns the plugins which are outdated
|
||||||
|
val outdatedPlugins = getPluginsOnline().map { savedData ->
|
||||||
|
onlinePlugins.filter { onlineData -> savedData.internalName == onlineData.second.internalName }
|
||||||
|
.mapNotNull { onlineData ->
|
||||||
|
val isOutdated =
|
||||||
|
onlineData.second.apiVersion != savedData.version || onlineData.second.version == PLUGIN_VERSION_ALWAYS_UPDATE
|
||||||
|
if (isOutdated) savedData to onlineData else null
|
||||||
|
}
|
||||||
|
}.flatten()
|
||||||
|
|
||||||
|
println("Outdated plugins: $outdatedPlugins")
|
||||||
|
|
||||||
|
outdatedPlugins.apmap {
|
||||||
|
downloadAndLoadPlugin(
|
||||||
|
context,
|
||||||
|
it.second.second.url,
|
||||||
|
it.first.internalName,
|
||||||
|
it.second.first
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Plugin update done!")
|
||||||
|
}
|
||||||
|
|
||||||
fun loadAllOnlinePlugins(context: Context) {
|
fun loadAllOnlinePlugins(context: Context) {
|
||||||
File(context.filesDir, ONLINE_PLUGINS_FOLDER).listFiles()?.sortedBy { it.name }
|
File(context.filesDir, ONLINE_PLUGINS_FOLDER).listFiles()?.sortedBy { it.name }
|
||||||
?.forEach { file ->
|
?.forEach { file ->
|
||||||
|
@ -130,12 +184,12 @@ object PluginManager {
|
||||||
* */
|
* */
|
||||||
private fun loadPlugin(context: Context, file: File, data: PluginData): Boolean {
|
private fun loadPlugin(context: Context, file: File, data: PluginData): Boolean {
|
||||||
val fileName = file.nameWithoutExtension
|
val fileName = file.nameWithoutExtension
|
||||||
setPluginData(data)
|
val filePath = file.absolutePath
|
||||||
println("Loading plugin: $data")
|
println("Loading plugin: $data")
|
||||||
|
|
||||||
//logger.info("Loading plugin: " + fileName);
|
//logger.info("Loading plugin: " + fileName);
|
||||||
return try {
|
return try {
|
||||||
val loader = PathClassLoader(file.absolutePath, context.classLoader)
|
val loader = PathClassLoader(filePath, context.classLoader)
|
||||||
var manifest: Plugin.Manifest
|
var manifest: Plugin.Manifest
|
||||||
loader.getResourceAsStream("manifest.json").use { stream ->
|
loader.getResourceAsStream("manifest.json").use { stream ->
|
||||||
if (stream == null) {
|
if (stream == null) {
|
||||||
|
@ -150,15 +204,21 @@ object PluginManager {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val name: String = manifest.name ?: "NO NAME"
|
val name: String = manifest.name ?: "NO NAME"
|
||||||
|
val version: Int = manifest.pluginVersion ?: PLUGIN_VERSION_NOT_SET
|
||||||
val pluginClass: Class<*> =
|
val pluginClass: Class<*> =
|
||||||
loader.loadClass(manifest.pluginClassName) as Class<out Plugin?>
|
loader.loadClass(manifest.pluginClassName) as Class<out Plugin?>
|
||||||
val pluginInstance: Plugin =
|
val pluginInstance: Plugin =
|
||||||
pluginClass.newInstance() as Plugin
|
pluginClass.newInstance() as Plugin
|
||||||
if (plugins.containsKey(name)) {
|
if (plugins.containsKey(filePath)) {
|
||||||
println("Plugin with name $name already exists")
|
println("Plugin with name $name already exists")
|
||||||
return false
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sets with the proper version
|
||||||
|
setPluginData(data.copy(version = version))
|
||||||
|
|
||||||
pluginInstance.__filename = fileName
|
pluginInstance.__filename = fileName
|
||||||
if (pluginInstance.needsResources) {
|
if (pluginInstance.needsResources) {
|
||||||
// based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk
|
// based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk
|
||||||
|
@ -172,9 +232,10 @@ object PluginManager {
|
||||||
context.resources.configuration
|
context.resources.configuration
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
plugins[name] = pluginInstance
|
plugins[filePath] = pluginInstance
|
||||||
classLoaders[loader] = pluginInstance
|
classLoaders[loader] = pluginInstance
|
||||||
pluginInstance.load(context)
|
pluginInstance.load(context)
|
||||||
|
println("Loaded plugin ${data.internalName} successfully")
|
||||||
true
|
true
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
failedToLoad[file] = e
|
failedToLoad[file] = e
|
||||||
|
@ -188,12 +249,24 @@ object PluginManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun downloadPlugin(context: Context, pluginUrl: String, name: String): Boolean {
|
suspend fun downloadAndLoadPlugin(
|
||||||
val file = downloadPluginToFile(context, pluginUrl, name)
|
context: Context,
|
||||||
|
pluginUrl: String,
|
||||||
|
internalName: String,
|
||||||
|
repositoryUrl: String
|
||||||
|
): Boolean {
|
||||||
|
val folderName = (sanitizeFilename(
|
||||||
|
repositoryUrl,
|
||||||
|
true
|
||||||
|
) + "." + repositoryUrl.hashCode()) // Guaranteed unique
|
||||||
|
val fileName = (sanitizeFilename(internalName, true) + "." + internalName.hashCode())
|
||||||
|
println("Downloading plugin: $pluginUrl to $folderName/$fileName")
|
||||||
|
// The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names
|
||||||
|
val file = downloadPluginToFile(context, pluginUrl, fileName, folderName)
|
||||||
return loadPlugin(
|
return loadPlugin(
|
||||||
context,
|
context,
|
||||||
file ?: return false,
|
file ?: return false,
|
||||||
PluginData(name, pluginUrl, true, file.absolutePath)
|
PluginData(internalName, pluginUrl, true, file.absolutePath, PLUGIN_VERSION_NOT_SET)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,16 +3,13 @@ package com.lagradost.cloudstream3.plugins
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
import com.lagradost.cloudstream3.PROVIDER_STATUS_OK
|
import com.lagradost.cloudstream3.apmapIndexed
|
||||||
import com.lagradost.cloudstream3.apmap
|
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
|
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
|
||||||
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
|
import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY
|
||||||
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData
|
||||||
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.io.BufferedInputStream
|
import java.io.BufferedInputStream
|
||||||
|
@ -36,17 +33,28 @@ data class Repository(
|
||||||
* 3: Beta only
|
* 3: Beta only
|
||||||
* */
|
* */
|
||||||
data class SitePlugin(
|
data class SitePlugin(
|
||||||
|
// Url to the .cs3 file
|
||||||
@JsonProperty("url") val url: String,
|
@JsonProperty("url") val url: String,
|
||||||
|
// Status to remotely disable the provider
|
||||||
@JsonProperty("status") val status: Int,
|
@JsonProperty("status") val status: Int,
|
||||||
|
// Integer over 0, any change of this will trigger an auto update
|
||||||
@JsonProperty("version") val version: Int,
|
@JsonProperty("version") val version: Int,
|
||||||
|
// Unused currently, used to make the api backwards compatible?
|
||||||
|
// Set to 1
|
||||||
@JsonProperty("apiVersion") val apiVersion: Int,
|
@JsonProperty("apiVersion") val apiVersion: Int,
|
||||||
|
// Name to be shown in app
|
||||||
@JsonProperty("name") val name: String,
|
@JsonProperty("name") val name: String,
|
||||||
|
// Name to be referenced internally. Separate to make name and url changes possible
|
||||||
|
@JsonProperty("internalName") val internalName: String,
|
||||||
@JsonProperty("authors") val authors: List<String>,
|
@JsonProperty("authors") val authors: List<String>,
|
||||||
@JsonProperty("description") val description: String?,
|
@JsonProperty("description") val description: String?,
|
||||||
|
// Might be used to go directly to the plugin repo in the future
|
||||||
@JsonProperty("repositoryUrl") val repositoryUrl: String?,
|
@JsonProperty("repositoryUrl") val repositoryUrl: String?,
|
||||||
|
// These types are yet to be mapped and used, ignore for now
|
||||||
@JsonProperty("tvTypes") val tvTypes: List<String>?,
|
@JsonProperty("tvTypes") val tvTypes: List<String>?,
|
||||||
@JsonProperty("language") val language: String?,
|
@JsonProperty("language") val language: String?,
|
||||||
@JsonProperty("iconUrl") val iconUrl: String?,
|
@JsonProperty("iconUrl") val iconUrl: String?,
|
||||||
|
// Set to true to get an 18+ symbol next to the plugin
|
||||||
@JsonProperty("isAdult") val isAdult: Boolean?,
|
@JsonProperty("isAdult") val isAdult: Boolean?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -69,21 +77,32 @@ object RepositoryManager {
|
||||||
return tryParseJson<Array<SitePlugin>>(response.text)?.toList() ?: emptyList()
|
return tryParseJson<Array<SitePlugin>>(response.text)?.toList() ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getRepoPlugins(repositoryUrl: String): List<SitePlugin>? {
|
/**
|
||||||
|
* Gets all plugins from repositories and pairs them with the repository url
|
||||||
|
* */
|
||||||
|
suspend fun getRepoPlugins(repositoryUrl: String): List<Pair<String, SitePlugin>>? {
|
||||||
val repo = parseRepository(repositoryUrl) ?: return null
|
val repo = parseRepository(repositoryUrl) ?: return null
|
||||||
return repo.pluginLists.apmap {
|
return repo.pluginLists.apmapIndexed { index, url ->
|
||||||
parsePlugins(it)
|
parsePlugins(url).map {
|
||||||
|
repo.pluginLists[index] to it
|
||||||
|
}
|
||||||
}.flatten()
|
}.flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun downloadPluginToFile(context: Context, pluginUrl: String, name: String): File? {
|
suspend fun downloadPluginToFile(context: Context, pluginUrl: String, fileName: String, folder: String): File? {
|
||||||
return suspendSafeApiCall {
|
return suspendSafeApiCall {
|
||||||
val extensionsDir = File(context.filesDir, ONLINE_PLUGINS_FOLDER)
|
val extensionsDir = File(context.filesDir, ONLINE_PLUGINS_FOLDER)
|
||||||
if (!extensionsDir.exists())
|
if (!extensionsDir.exists())
|
||||||
extensionsDir.mkdirs()
|
extensionsDir.mkdirs()
|
||||||
|
|
||||||
val newFile = File(extensionsDir, "$name.${pluginUrl.hashCode()}.cs3")
|
val newDir = File(extensionsDir, folder)
|
||||||
if (newFile.exists()) return@suspendSafeApiCall newFile
|
newDir.mkdirs()
|
||||||
|
|
||||||
|
val newFile = File(newDir, "${fileName}.cs3")
|
||||||
|
// Overwrite if exists
|
||||||
|
if (newFile.exists()) {
|
||||||
|
newFile.delete()
|
||||||
|
}
|
||||||
newFile.createNewFile()
|
newFile.createNewFile()
|
||||||
|
|
||||||
val body = app.get(pluginUrl).okhttpResponse.body
|
val body = app.get(pluginUrl).okhttpResponse.body
|
||||||
|
@ -95,21 +114,20 @@ object RepositoryManager {
|
||||||
// Don't want to read before we write in another thread
|
// Don't want to read before we write in another thread
|
||||||
private val repoLock = Mutex()
|
private val repoLock = Mutex()
|
||||||
suspend fun addRepository(repository: RepositoryData) {
|
suspend fun addRepository(repository: RepositoryData) {
|
||||||
repoLock.withLock {
|
repoLock.withLock {
|
||||||
val currentRepos = getKey<Array<RepositoryData>>(REPOSITORIES_KEY) ?: emptyArray()
|
val currentRepos = getKey<Array<RepositoryData>>(REPOSITORIES_KEY) ?: emptyArray()
|
||||||
// No duplicates
|
// No duplicates
|
||||||
if (currentRepos.any { it.url == repository.url }) return
|
setKey(REPOSITORIES_KEY, (currentRepos + repository).distinctBy { it.url })
|
||||||
setKey(REPOSITORIES_KEY, currentRepos + repository)
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun removeRepository(repository: RepositoryData) {
|
suspend fun removeRepository(repository: RepositoryData) {
|
||||||
repoLock.withLock {
|
repoLock.withLock {
|
||||||
val currentRepos = getKey<Array<RepositoryData>>(REPOSITORIES_KEY) ?: emptyArray()
|
val currentRepos = getKey<Array<RepositoryData>>(REPOSITORIES_KEY) ?: emptyArray()
|
||||||
// No duplicates
|
// No duplicates
|
||||||
val newRepos = currentRepos.filter { it.url != repository.url }
|
val newRepos = currentRepos.filter { it.url != repository.url }
|
||||||
setKey(REPOSITORIES_KEY, newRepos)
|
setKey(REPOSITORIES_KEY, newRepos)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun write(stream: InputStream, output: OutputStream) {
|
private fun write(stream: InputStream, output: OutputStream) {
|
||||||
|
|
|
@ -25,7 +25,7 @@ class ExtensionsViewModel : ViewModel() {
|
||||||
_repositories.postValue(urls)
|
_repositories.postValue(urls)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun getPlugins(repositoryUrl: String): List<SitePlugin> {
|
suspend fun getPlugins(repositoryUrl: String): List<Pair<String, SitePlugin>> {
|
||||||
return RepositoryManager.getRepoPlugins(repositoryUrl) ?: emptyList()
|
return RepositoryManager.getRepoPlugins(repositoryUrl) ?: emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -12,8 +12,8 @@ import com.lagradost.cloudstream3.plugins.SitePlugin
|
||||||
import kotlinx.android.synthetic.main.repository_item.view.*
|
import kotlinx.android.synthetic.main.repository_item.view.*
|
||||||
|
|
||||||
class PluginAdapter(
|
class PluginAdapter(
|
||||||
var plugins: List<SitePlugin>,
|
var plugins: List<Pair<String, SitePlugin>>,
|
||||||
val iconClickCallback: PluginAdapter.(plugin: SitePlugin, isDownloaded: Boolean) -> Unit
|
val iconClickCallback: PluginAdapter.(repositoryUrl: String, plugin: SitePlugin, isDownloaded: Boolean) -> Unit
|
||||||
) :
|
) :
|
||||||
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
@ -23,9 +23,10 @@ class PluginAdapter(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
val (repositoryUrl, plugin) = plugins[position]
|
||||||
when (holder) {
|
when (holder) {
|
||||||
is PluginViewHolder -> {
|
is PluginViewHolder -> {
|
||||||
holder.bind(plugins[position])
|
holder.bind(repositoryUrl, plugin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -44,7 +45,8 @@ class PluginAdapter(
|
||||||
RecyclerView.ViewHolder(itemView) {
|
RecyclerView.ViewHolder(itemView) {
|
||||||
|
|
||||||
fun bind(
|
fun bind(
|
||||||
plugin: SitePlugin
|
repositoryUrl: String,
|
||||||
|
plugin: SitePlugin,
|
||||||
) {
|
) {
|
||||||
val isDownloaded = storedPlugins.any { it.url == plugin.url }
|
val isDownloaded = storedPlugins.any { it.url == plugin.url }
|
||||||
|
|
||||||
|
@ -56,7 +58,7 @@ class PluginAdapter(
|
||||||
itemView.action_button?.setImageResource(drawableInt)
|
itemView.action_button?.setImageResource(drawableInt)
|
||||||
|
|
||||||
itemView.action_button?.setOnClickListener {
|
itemView.action_button?.setOnClickListener {
|
||||||
iconClickCallback.invoke(this@PluginAdapter, plugin, isDownloaded)
|
iconClickCallback.invoke(this@PluginAdapter, repositoryUrl, plugin, isDownloaded)
|
||||||
}
|
}
|
||||||
|
|
||||||
itemView.main_text?.text = plugin.name
|
itemView.main_text?.text = plugin.name
|
||||||
|
|
|
@ -10,7 +10,6 @@ import androidx.fragment.app.activityViewModels
|
||||||
import com.lagradost.cloudstream3.CommonActivity.showToast
|
import com.lagradost.cloudstream3.CommonActivity.showToast
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.plugins.PluginManager
|
import com.lagradost.cloudstream3.plugins.PluginManager
|
||||||
import com.lagradost.cloudstream3.plugins.RepositoryManager
|
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
|
||||||
|
@ -44,29 +43,35 @@ class PluginsFragment : Fragment() {
|
||||||
ioSafe {
|
ioSafe {
|
||||||
val plugins = extensionViewModel.getPlugins(url)
|
val plugins = extensionViewModel.getPlugins(url)
|
||||||
main {
|
main {
|
||||||
repo_recycler_view?.adapter = PluginAdapter(plugins) { plugin, isDownloaded ->
|
repo_recycler_view?.adapter =
|
||||||
ioSafe {
|
PluginAdapter(plugins) { repositoryUrl, plugin, isDownloaded ->
|
||||||
val (success, message) = if (isDownloaded) {
|
ioSafe {
|
||||||
PluginManager.deletePlugin(view.context, plugin.url, plugin.name) to R.string.plugin_deleted
|
val (success, message) = if (isDownloaded) {
|
||||||
} else {
|
PluginManager.deletePlugin(
|
||||||
PluginManager.downloadPlugin(
|
view.context,
|
||||||
view.context,
|
plugin.url,
|
||||||
plugin.url,
|
plugin.name
|
||||||
plugin.name
|
) to R.string.plugin_deleted
|
||||||
) to R.string.plugin_loaded
|
} else {
|
||||||
}
|
PluginManager.downloadAndLoadPlugin(
|
||||||
|
view.context,
|
||||||
|
plugin.url,
|
||||||
|
plugin.name,
|
||||||
|
repositoryUrl
|
||||||
|
) to R.string.plugin_loaded
|
||||||
|
}
|
||||||
|
|
||||||
println("Success: $success")
|
println("Success: $success")
|
||||||
if (success) {
|
if (success) {
|
||||||
main {
|
main {
|
||||||
showToast(activity, message, Toast.LENGTH_SHORT)
|
showToast(activity, message, Toast.LENGTH_SHORT)
|
||||||
this@PluginAdapter.reloadStoredPlugins()
|
this@PluginAdapter.reloadStoredPlugins()
|
||||||
// Dirty and needs a fix
|
// Dirty and needs a fix
|
||||||
repo_recycler_view?.adapter?.notifyDataSetChanged()
|
repo_recycler_view?.adapter?.notifyDataSetChanged()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -426,11 +426,12 @@ object VideoDownloadManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val reservedChars = "|\\?*<\":>+[]/\'"
|
private const val reservedChars = "|\\?*<\":>+[]/\'"
|
||||||
fun sanitizeFilename(name: String): String {
|
fun sanitizeFilename(name: String, removeSpaces: Boolean= false): String {
|
||||||
var tempName = name
|
var tempName = name
|
||||||
for (c in reservedChars) {
|
for (c in reservedChars) {
|
||||||
tempName = tempName.replace(c, ' ')
|
tempName = tempName.replace(c, ' ')
|
||||||
}
|
}
|
||||||
|
if (removeSpaces) tempName = tempName.replace(" ", "")
|
||||||
return tempName.replace(" ", " ").trim(' ')
|
return tempName.replace(" ", " ").trim(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue