diff --git a/app/src/main/java/com/futo/platformplayer/Settings.kt b/app/src/main/java/com/futo/platformplayer/Settings.kt index 6081fc4f..d5adb409 100644 --- a/app/src/main/java/com/futo/platformplayer/Settings.kt +++ b/app/src/main/java/com/futo/platformplayer/Settings.kt @@ -311,7 +311,10 @@ class Settings : FragmentedStorageFileJson() { @FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 14) var alwaysReloadFromCache: Boolean = false; - @FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 15) + @FormField(R.string.peek_channel_contents, FieldForm.TOGGLE, R.string.peek_channel_contents_description, 15) + var peekChannelContents: Boolean = false; + + @FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 16) fun clearChannelCache() { UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing.."); StateCache.instance.clear(); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/CachedPlatformClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/CachedPlatformClient.kt index 57f53567..6f96fc2d 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/CachedPlatformClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/CachedPlatformClient.kt @@ -60,6 +60,9 @@ class CachedPlatformClient : IPlatformClient { filters: Map>? ): IPager = _client.getChannelContents(channelUrl); + override fun getPeekChannelTypes(): List = _client.getPeekChannelTypes(); + override fun peekChannelContents(channelUrl: String, type: String?): List = _client.peekChannelContents(channelUrl, type); + override fun getChannelUrlByClaim(claimType: Int, claimValues: Map): String? = _client.getChannelUrlByClaim(claimType, claimValues) override fun searchSuggestions(query: String): Array = _client.searchSuggestions(query); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt index b527d6ff..fd09adb3 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/IPlatformClient.kt @@ -84,6 +84,15 @@ interface IPlatformClient { */ fun getChannelContents(channelUrl: String, type: String? = null, order: String? = null, filters: Map>? = null): IPager; + /** + * Describes what the plugin is capable on peek channel results + */ + fun getPeekChannelTypes(): List; + /** + * Peeks contents of a channel, upload time descending + */ + fun peekChannelContents(channelUrl: String, type: String? = null): List + /** * Gets the channel url associated with a claimType */ diff --git a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt index 234ce74f..51958812 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/PlatformClientCapabilities.kt @@ -17,7 +17,8 @@ data class PlatformClientCapabilities( val hasGetChannelCapabilities: Boolean = false, val hasGetLiveEvents: Boolean = false, val hasGetLiveChatWindow: Boolean = false, - val hasGetContentChapters: Boolean = false + val hasGetContentChapters: Boolean = false, + val hasPeekChannelContents: Boolean = false ) { } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt index bae8aad6..0fe2d55b 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/PlatformAuthorLink.kt @@ -14,7 +14,7 @@ open class PlatformAuthorLink { val id: PlatformID; val name: String; val url: String; - val thumbnail: String?; + var thumbnail: String?; var subscribers: Long? = null; //Optional constructor(id: PlatformID, name: String, url: String, thumbnail: String? = null, subscribers: Long? = null) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt index 258995d9..64ceb31a 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/JSClient.kt @@ -27,6 +27,7 @@ import com.futo.platformplayer.api.media.platforms.js.internal.JSDocsParameter import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.api.media.platforms.js.internal.JSOptional import com.futo.platformplayer.api.media.platforms.js.internal.JSParameterDocs +import com.futo.platformplayer.api.media.platforms.js.models.IJSContent import com.futo.platformplayer.api.media.platforms.js.models.IJSContentDetails import com.futo.platformplayer.api.media.platforms.js.models.JSChannel import com.futo.platformplayer.api.media.platforms.js.models.JSChannelPager @@ -56,7 +57,6 @@ import com.futo.platformplayer.states.StatePlugins import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.time.OffsetDateTime -import java.util.Dictionary import kotlin.reflect.full.findAnnotations import kotlin.reflect.jvm.kotlinFunction import kotlin.streams.asSequence @@ -75,6 +75,7 @@ open class JSClient : IPlatformClient { private var _searchCapabilities: ResultCapabilities? = null; private var _searchChannelContentsCapabilities: ResultCapabilities? = null; private var _channelCapabilities: ResultCapabilities? = null; + private var _peekChannelTypes: List? = null; protected val _script: String; @@ -93,7 +94,11 @@ open class JSClient : IPlatformClient { private val _busyLock = Object(); private var _busyCounter = 0; + private var _busyAction = ""; val isBusy: Boolean get() = _busyCounter > 0; + val isBusyAction: String get() { + return _busyAction; + } val settings: HashMap get() = descriptor.settings; @@ -152,6 +157,8 @@ open class JSClient : IPlatformClient { if(it is ScriptCaptchaRequiredException) onCaptchaException.emit(this, it); }; + + _plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit); } constructor(context: Context, descriptor: SourcePluginDescriptor, saveState: String?, script: String) { this._context = context; @@ -175,6 +182,8 @@ open class JSClient : IPlatformClient { if(it is ScriptCaptchaRequiredException) onCaptchaException.emit(this, it); }; + + _plugin.changeAllowDevSubmit(descriptor.appSettings.allowDeveloperSubmit); } open fun getCopy(): JSClient { @@ -220,6 +229,7 @@ open class JSClient : IPlatformClient { hasGetLiveEvents = plugin.executeBoolean("!!source.getLiveEvents") ?: false, hasGetLiveChatWindow = plugin.executeBoolean("!!source.getLiveChatWindow") ?: false, hasGetContentChapters = plugin.executeBoolean("!!source.getContentChapters") ?: false, + hasPeekChannelContents = plugin.executeBoolean("!!source.peekChannelContents") ?: false ); try { @@ -263,7 +273,7 @@ open class JSClient : IPlatformClient { } @JSDocs(2, "source.getHome()", "Gets the HomeFeed of the platform") - override fun getHome(): IPager = isBusyWith { + override fun getHome(): IPager = isBusyWith("getHome") { ensureEnabled(); return@isBusyWith JSContentPager(config, this, plugin.executeTyped("source.getHome()")); @@ -271,7 +281,7 @@ open class JSClient : IPlatformClient { @JSDocs(3, "source.searchSuggestions(query)", "Gets search suggestions for a given query") @JSDocsParameter("query", "Query to complete suggestions for") - override fun searchSuggestions(query: String): Array = isBusyWith { + override fun searchSuggestions(query: String): Array = isBusyWith("searchSuggestions") { ensureEnabled(); return@isBusyWith plugin.executeTyped("source.searchSuggestions(${Json.encodeToString(query)})") .toArray() @@ -301,7 +311,7 @@ open class JSClient : IPlatformClient { @JSDocsParameter("order", "(optional) Order in which contents should be returned") @JSDocsParameter("filters", "(optional) Filters to apply on contents") @JSDocsParameter("channelId", "(optional) Channel id to search in") - override fun search(query: String, type: String?, order: String?, filters: Map>?): IPager = isBusyWith { + override fun search(query: String, type: String?, order: String?, filters: Map>?): IPager = isBusyWith("search") { ensureEnabled(); return@isBusyWith JSContentPager(config, this, plugin.executeTyped("source.search(${Json.encodeToString(query)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})")); @@ -325,7 +335,7 @@ open class JSClient : IPlatformClient { @JSDocsParameter("type", "(optional) Type of contents to get from search ") @JSDocsParameter("order", "(optional) Order in which contents should be returned") @JSDocsParameter("filters", "(optional) Filters to apply on contents") - override fun searchChannelContents(channelUrl: String, query: String, type: String?, order: String?, filters: Map>?): IPager = isBusyWith { + override fun searchChannelContents(channelUrl: String, query: String, type: String?, order: String?, filters: Map>?): IPager = isBusyWith("searchChannelContents") { ensureEnabled(); if(!capabilities.hasSearchChannelContents) throw IllegalStateException("This plugin does not support channel search"); @@ -337,7 +347,7 @@ open class JSClient : IPlatformClient { @JSOptional @JSDocs(5, "source.searchChannels(query)", "Searches for channels on the platform") @JSDocsParameter("query", "Query that channels should match") - override fun searchChannels(query: String): IPager = isBusyWith { + override fun searchChannels(query: String): IPager = isBusyWith("searchChannels") { ensureEnabled(); return@isBusyWith JSChannelPager(config, this, plugin.executeTyped("source.searchChannels(${Json.encodeToString(query)})")); @@ -357,7 +367,7 @@ open class JSClient : IPlatformClient { } @JSDocs(7, "source.getChannel(channelUrl)", "Gets a channel by its url") @JSDocsParameter("channelUrl", "A channel url (this platform)") - override fun getChannel(channelUrl: String): IPlatformChannel = isBusyWith { + override fun getChannel(channelUrl: String): IPlatformChannel = isBusyWith("getChannel") { ensureEnabled(); return@isBusyWith JSChannel(config, plugin.executeTyped("source.getChannel(${Json.encodeToString(channelUrl)})")); @@ -384,12 +394,46 @@ open class JSClient : IPlatformClient { @JSDocsParameter("type", "(optional) Type of contents to get from channel") @JSDocsParameter("order", "(optional) Order in which contents should be returned") @JSDocsParameter("filters", "(optional) Filters to apply on contents") - override fun getChannelContents(channelUrl: String, type: String?, order: String?, filters: Map>?): IPager = isBusyWith { + override fun getChannelContents(channelUrl: String, type: String?, order: String?, filters: Map>?): IPager = isBusyWith("getChannelContents") { ensureEnabled(); return@isBusyWith JSContentPager(config, this, plugin.executeTyped("source.getChannelContents(${Json.encodeToString(channelUrl)}, ${Json.encodeToString(type)}, ${Json.encodeToString(order)}, ${Json.encodeToString(filters)})")); } + @JSDocs(10, "source.getPeekChannelTypes()", "Gets types this plugin has for peek channel contents") + override fun getPeekChannelTypes(): List { + if(!capabilities.hasPeekChannelContents) + return listOf(); + try { + if (_peekChannelTypes != null) { + return _peekChannelTypes!!; + } + val arr: V8ValueArray = plugin.executeTyped("source.getPeekChannelTypes()"); + + _peekChannelTypes = arr.keys.mapNotNull { + val str = arr.get(it); + return@mapNotNull str.value; + }; + return _peekChannelTypes ?: listOf(); + } + catch(ex: Throwable) { + announcePluginUnhandledException("getPeekChannelTypes", ex); + return listOf(); + } + } + @JSDocs(10, "source.peekChannelContents(url, type)", "Peek contents of a channel (reverse chronological order)") + @JSDocsParameter("channelUrl", "A channel url (this platform)") + @JSDocsParameter("type", "(optional) Type of contents to get from channel") + override fun peekChannelContents(channelUrl: String, type: String?): List = isBusyWith("peekChannelContents") { + ensureEnabled(); + + val items: V8ValueArray = plugin.executeTyped("source.peekChannelContents(${Json.encodeToString(channelUrl)}, ${Json.encodeToString(type)})"); + return@isBusyWith items.keys.mapNotNull { + val obj = items.get(it); + return@mapNotNull IJSContent.fromV8(this, obj); + }; + } + @JSOptional @JSDocs(11, "source.getChannelUrlByClaim(claimType, claimValues)", "Gets the channel url that should be used to fetch a given polycentric claim") @JSDocsParameter("claimType", "Polycentric claimtype id") @@ -450,7 +494,7 @@ open class JSClient : IPlatformClient { } @JSDocs(14, "source.getContentDetails(url)", "Gets content details by its url") @JSDocsParameter("url", "A content url (this platform)") - override fun getContentDetails(url: String): IPlatformContentDetails = isBusyWith { + override fun getContentDetails(url: String): IPlatformContentDetails = isBusyWith("getContentDetails") { ensureEnabled(); return@isBusyWith IJSContentDetails.fromV8(this, plugin.executeTyped("source.getContentDetails(${Json.encodeToString(url)})")); @@ -459,7 +503,7 @@ open class JSClient : IPlatformClient { @JSOptional //getContentChapters = function(url, initialData) @JSDocs(15, "source.getContentChapters(url)", "Gets chapters for content details") @JSDocsParameter("url", "A content url (this platform)") - override fun getContentChapters(url: String): List = isBusyWith { + override fun getContentChapters(url: String): List = isBusyWith("getContentChapters") { if(!capabilities.hasGetContentChapters) return@isBusyWith listOf(); ensureEnabled(); @@ -470,7 +514,7 @@ open class JSClient : IPlatformClient { @JSOptional @JSDocs(15, "source.getPlaybackTracker(url)", "Gets a playback tracker for given content url") @JSDocsParameter("url", "A content url (this platform)") - override fun getPlaybackTracker(url: String): IPlaybackTracker? = isBusyWith { + override fun getPlaybackTracker(url: String): IPlaybackTracker? = isBusyWith("getPlaybackTracker") { if(!capabilities.hasGetPlaybackTracker) return@isBusyWith null; ensureEnabled(); @@ -484,7 +528,7 @@ open class JSClient : IPlatformClient { @JSDocs(16, "source.getComments(url)", "Gets comments for a content by its url") @JSDocsParameter("url", "A content url (this platform)") - override fun getComments(url: String): IPager = isBusyWith { + override fun getComments(url: String): IPager = isBusyWith("getComments") { ensureEnabled(); val pager = plugin.executeTyped("source.getComments(${Json.encodeToString(url)})"); if (pager !is V8ValueObject) { //TODO: Maybe solve this better @@ -502,7 +546,7 @@ open class JSClient : IPlatformClient { @JSDocs(16, "source.getLiveChatWindow(url)", "Gets live events for a livestream") @JSDocsParameter("url", "Url of live stream") - override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? = isBusyWith { + override fun getLiveChatWindow(url: String): ILiveChatWindowDescriptor? = isBusyWith("getLiveChatWindow") { if(!capabilities.hasGetLiveChatWindow) return@isBusyWith null; ensureEnabled(); @@ -511,7 +555,7 @@ open class JSClient : IPlatformClient { } @JSDocs(16, "source.getLiveEvents(url)", "Gets live events for a livestream") @JSDocsParameter("url", "Url of live stream") - override fun getLiveEvents(url: String): IPager? = isBusyWith { + override fun getLiveEvents(url: String): IPager? = isBusyWith("getLiveEvents") { if(!capabilities.hasGetLiveEvents) return@isBusyWith null; ensureEnabled(); @@ -524,7 +568,7 @@ open class JSClient : IPlatformClient { @JSDocsParameter("order", "(optional) Order in which contents should be returned") @JSDocsParameter("filters", "(optional) Filters to apply on contents") @JSDocsParameter("channelId", "(optional) Channel id to search in") - override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map>?): IPager = isBusyWith { + override fun searchPlaylists(query: String, type: String?, order: String?, filters: Map>?): IPager = isBusyWith("searchPlaylists") { ensureEnabled(); if(!capabilities.hasSearchPlaylists) throw IllegalStateException("This plugin does not support playlist search"); @@ -534,15 +578,22 @@ open class JSClient : IPlatformClient { @JSDocs(20, "source.isPlaylistUrl(url)", "Validates if a playlist url is for this platform") @JSDocsParameter("url", "Url of playlist") override fun isPlaylistUrl(url: String): Boolean { - ensureEnabled(); if (!capabilities.hasGetPlaylist) return false; - return plugin.executeBoolean("source.isPlaylistUrl(${Json.encodeToString(url)})") ?: false; + + try { + return plugin.executeTyped("source.isPlaylistUrl(${Json.encodeToString(url)})") + .value; + } + catch(ex: Throwable) { + announcePluginUnhandledException("isPlaylistUrl", ex); + return false; + } } @JSOptional @JSDocs(21, "source.getPlaylist(url)", "Gets the playlist of the current user") @JSDocsParameter("url", "Url of playlist") - override fun getPlaylist(url: String): IPlatformPlaylistDetails = isBusyWith { + override fun getPlaylist(url: String): IPlatformPlaylistDetails = isBusyWith("getPlaylist") { ensureEnabled(); return@isBusyWith JSPlaylistDetails(this, plugin.config as SourcePluginConfig, plugin.executeTyped("source.getPlaylist(${Json.encodeToString(url)})")); } @@ -639,19 +690,24 @@ open class JSClient : IPlatformClient { } - private fun isBusyWith(handle: ()->T): T { + private fun isBusyWith(actionName: String, handle: ()->T): T { try { synchronized(_busyLock) { _busyCounter++; } + _busyAction = actionName; return handle(); } finally { + _busyAction = ""; synchronized(_busyLock) { _busyCounter--; } } } + private fun isBusyWith(handle: ()->T): T { + return isBusyWith("Unknown", handle); + } private fun announcePluginUnhandledException(method: String, ex: Throwable) { if(ex is PluginEngineException) diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt index b35f9ade..30196913 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginConfig.kt @@ -45,7 +45,8 @@ class SourcePluginConfig( var enableInSearch: Boolean = true, var enableInHome: Boolean = true, var supportedClaimTypes: List = listOf(), - var primaryClaimFieldType: Int? = null + var primaryClaimFieldType: Int? = null, + var developerSubmitUrl: String? = null ) : IV8PluginConfig { val absoluteIconUrl: String? get() = resolveAbsoluteUrl(iconUrl, sourceUrl); diff --git a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt index ce873f53..bea18399 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/platforms/js/SourcePluginDescriptor.kt @@ -8,6 +8,7 @@ import com.futo.platformplayer.states.StateAnnouncement import com.futo.platformplayer.views.fields.DropdownFieldOptions import com.futo.platformplayer.views.fields.FieldForm import com.futo.platformplayer.views.fields.FormField +import com.futo.platformplayer.views.fields.FormFieldWarning import kotlinx.serialization.Serializable @Serializable @@ -90,7 +91,7 @@ class SourcePluginDescriptor { @Serializable class AppPluginSettings { - @FormField(R.string.check_for_updates_setting, FieldForm.TOGGLE, R.string.check_for_updates_setting_description, 1) + @FormField(R.string.check_for_updates_setting, FieldForm.TOGGLE, R.string.check_for_updates_setting_description, 0) var checkForUpdates: Boolean = true; @FormField(R.string.visibility, "group", R.string.enable_where_this_plugins_content_are_visible, 2) @@ -130,6 +131,11 @@ class SourcePluginDescriptor { } + + @FormField(R.string.allow_developer_submit, FieldForm.TOGGLE, R.string.allow_developer_submit_description, 1, "devSubmit") + var allowDeveloperSubmit: Boolean = false; + + fun loadDefaults(config: SourcePluginConfig) { if(tabEnabled.enableHome == null) tabEnabled.enableHome = config.enableInHome diff --git a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt index 4fe088c3..432bb114 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/V8Plugin.kt @@ -11,7 +11,6 @@ import com.caoccao.javet.values.primitive.V8ValueBoolean import com.caoccao.javet.values.primitive.V8ValueInteger import com.caoccao.javet.values.primitive.V8ValueString import com.caoccao.javet.values.reference.V8ValueObject -import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.constructs.Event1 @@ -45,7 +44,6 @@ class V8Plugin { private val _clientAuth: ManagedHttpClient; private val _clientOthers: ConcurrentHashMap = ConcurrentHashMap(); - val httpClient: ManagedHttpClient get() = _client; val httpClientAuth: ManagedHttpClient get() = _clientAuth; val httpClientOthers: Map get() = _clientOthers; @@ -71,6 +69,11 @@ class V8Plugin { private var _busyCounter = 0; val isBusy get() = synchronized(_busyCounterLock) { _busyCounter > 0 }; + var allowDevSubmit: Boolean = false + private set(value) { + field = value; + } + /** * Called before a busy counter is about to be removed. * Is primarily used to prevent additional calls to dead runtimes. @@ -92,6 +95,10 @@ class V8Plugin { withDependency(getPackage(pack)); } + fun changeAllowDevSubmit(isAllowed: Boolean) { + allowDevSubmit = isAllowed; + } + fun withDependency(context: Context, assetPath: String) : V8Plugin { if(!_deps.containsKey(assetPath)) _deps.put(assetPath, getAssetFile(context, assetPath)); diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt index 0760a609..d07720e8 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageBridge.kt @@ -5,6 +5,7 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.states.StateDeveloper import com.futo.platformplayer.UIDialogs import com.futo.platformplayer.api.http.ManagedHttpClient +import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.internal.JSHttpClient import com.futo.platformplayer.engine.IV8PluginConfig @@ -12,6 +13,9 @@ import com.futo.platformplayer.engine.V8Plugin import com.futo.platformplayer.states.StateApp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json class PackageBridge : V8Package { @Transient @@ -21,6 +25,7 @@ class PackageBridge : V8Package { @Transient private val _clientAuth: ManagedHttpClient + override val name: String get() = "Bridge"; override val variableName: String get() = "bridge"; @@ -47,6 +52,44 @@ class PackageBridge : V8Package { StateDeveloper.instance.logDevInfo(StateDeveloper.instance.currentDevID ?: "", str ?: "null"); } + private val _jsonSerializer = Json { this.prettyPrintIndent = " "; this.prettyPrint = true; }; + private var _devSubmitClient: ManagedHttpClient? = null; + @V8Function + fun devSubmit(label: String, data: String) { + if(_plugin.config !is SourcePluginConfig) + return; + if(!_plugin.allowDevSubmit) + return; + val devUrl = _plugin.config.developerSubmitUrl ?: return; + if(_devSubmitClient == null) + _devSubmitClient = ManagedHttpClient(); + + val stackTrace = Thread.currentThread().stackTrace; + val callerMethod = stackTrace.findLast { + it.className == JSClient::class.java.name + }?.methodName ?: ""; + val session = StateApp.instance.sessionId; + val pluginId = _plugin.config.id; + val pluginVersion = _plugin.config.version; + + val obj = DevSubmitData(pluginId, pluginVersion, callerMethod, session, label, data); + + UIDialogs.toast("DevSubmit [${callerMethod}] (${_plugin.config.name})", false); + StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) { + try { + val json = _jsonSerializer.encodeToString(obj); + Logger.i(TAG, "DevSubmit [${callerMethod}] - ${devUrl}\n" + json); + val resp = _devSubmitClient?.post(devUrl, json, mutableMapOf(Pair("Content-Type", "application/json"))); + Logger.i(TAG, "DevSubmit [${callerMethod}] - ${devUrl} Status: " + (resp?.code?.toString() ?: "-1")) + } + catch(ex: Exception) { + Logger.e(TAG, "DevSubmission to [${devUrl}] failed due to:\n" + ex.message, ex); + } + } + } + @Serializable + class DevSubmitData(val pluginId: String, val pluginVersion: Int, val caller: String, val session: String, val label: String, val data: String) + @V8Function fun throwTest(str: String) { throw IllegalStateException(str); diff --git a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageUtilities.kt b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageUtilities.kt index 58653dc0..5982ffbd 100644 --- a/app/src/main/java/com/futo/platformplayer/engine/packages/PackageUtilities.kt +++ b/app/src/main/java/com/futo/platformplayer/engine/packages/PackageUtilities.kt @@ -22,12 +22,12 @@ class PackageUtilities : V8Package { @V8Function fun toBase64(arr: ByteArray): String { - return Base64.encodeToString(arr, Base64.NO_WRAP); + return Base64.encodeToString(arr, Base64.NO_PADDING or Base64.NO_WRAP); } @V8Function fun fromBase64(str: String): ByteArray { - return Base64.decode(str, Base64.DEFAULT) + return Base64.decode(str, Base64.NO_PADDING or Base64.NO_WRAP) } @V8Function diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt index e13e2d5a..ae584d40 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/DownloadsFragment.kt @@ -153,10 +153,10 @@ class DownloadsFragment : MainFragment() { _listActiveDownloadsContainer.visibility = GONE; else { _listActiveDownloadsContainer.visibility = VISIBLE; - _listActiveDownloadsMeta.text = "(${activeDownloads.size})"; + _listActiveDownloadsMeta.text = "(${activeDownloads.size} videos)"; _listActiveDownloads.removeAllViews(); - for(view in activeDownloads.map { ActiveDownloadItem(context, it, _frag.lifecycleScope) }) + for(view in activeDownloads.take(4).map { ActiveDownloadItem(context, it, _frag.lifecycleScope) }) _listActiveDownloads.addView(view); } diff --git a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt index 1c5b50a4..37bc7c66 100644 --- a/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt +++ b/app/src/main/java/com/futo/platformplayer/fragment/mainactivity/main/SourceDetailFragment.kt @@ -12,6 +12,7 @@ import android.webkit.CookieManager import android.widget.FrameLayout import android.widget.ImageView import android.widget.LinearLayout +import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.futo.platformplayer.R import com.futo.platformplayer.Settings @@ -107,17 +108,20 @@ class SourceDetailFragment : MainFragment() { fun onHide() { val id = _config?.id ?: return; - if(_settingsChanged && _settings != null) { - _settingsChanged = false; - StatePlugins.instance.setPluginSettings(id, _settings!!); - reloadSource(id); - - UIDialogs.toast(context.getString(R.string.plugin_settings_saved), false); - } + var shouldReload = false; if(_settingsAppChanged) { _settingsAppForm.setObjectValues(); StatePlugins.instance.savePlugin(id); + shouldReload = true; } + if(_settingsChanged && _settings != null) { + _settingsChanged = false; + StatePlugins.instance.setPluginSettings(id, _settings!!); + shouldReload = true; + UIDialogs.toast(context.getString(R.string.plugin_settings_saved), false); + } + if(shouldReload) + reloadSource(id); } @@ -137,9 +141,25 @@ class SourceDetailFragment : MainFragment() { //App settings try { _settingsAppForm.fromObject(source.descriptor.appSettings); + if(source.config.developerSubmitUrl.isNullOrEmpty()) { + val field = _settingsAppForm.findField("devSubmit"); + field?.setValue(false); + if(field is View) + field.isVisible = false; + } _settingsAppForm.onChanged.clear(); - _settingsAppForm.onChanged.subscribe { _, _ -> + _settingsAppForm.onChanged.subscribe { field, value -> _settingsAppChanged = true; + if(field.descriptor?.id == "devSubmit") { + if(value is Boolean && value) { + UIDialogs.showDialog(context, R.drawable.ic_warning_yellow, + "Are you sure you trust the developer?", + "Developers may gain access to sensitive data. Only enable this when you are trying to help the developer fix a bug.\nThe following domain is used:", + source.config.developerSubmitUrl ?: "", 0, + UIDialogs.Action("Cancel", { field.setValue(false); }, UIDialogs.ActionStyle.NONE), + UIDialogs.Action("Enable", { }, UIDialogs.ActionStyle.DANGEROUS)); + } + } } } catch (e: Throwable) { Logger.e(TAG, "Failed to load app settings form from plugin settings", e) diff --git a/app/src/main/java/com/futo/platformplayer/models/Subscription.kt b/app/src/main/java/com/futo/platformplayer/models/Subscription.kt index 00435ba2..8ec98efc 100644 --- a/app/src/main/java/com/futo/platformplayer/models/Subscription.kt +++ b/app/src/main/java/com/futo/platformplayer/models/Subscription.kt @@ -40,6 +40,9 @@ class Subscription { @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) var lastPostUpdate : OffsetDateTime = OffsetDateTime.MIN; + @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) + var lastPeekVideo : OffsetDateTime = OffsetDateTime.MIN; + //Last video interval var uploadInterval : Int = 0; var uploadStreamInterval : Int = 0; @@ -126,6 +129,7 @@ class Subscription { else if(lastVideo.year > 3000) lastVideo = OffsetDateTime.MIN; lastVideoUpdate = OffsetDateTime.now(); + lastPeekVideo = OffsetDateTime.MIN; } ResultCapabilities.TYPE_MIXED -> { uploadInterval = interval; @@ -134,6 +138,7 @@ class Subscription { else if(lastVideo.year > 3000) lastVideo = OffsetDateTime.MIN; lastVideoUpdate = OffsetDateTime.now(); + lastPeekVideo = OffsetDateTime.MIN; } ResultCapabilities.TYPE_SUBSCRIPTIONS -> { uploadInterval = interval; diff --git a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt index 144c1dc9..3df39baa 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateApp.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateApp.kt @@ -54,6 +54,9 @@ import kotlin.system.measureTimeMillis class StateApp { val isMainActive: Boolean get() = contextOrNull != null && contextOrNull is MainActivity; //if context is MainActivity, it means its active + val sessionId = UUID.randomUUID().toString(); + + fun getExternalGeneralDirectory(context: Context): DocumentFile? { val generalUri = Settings.instance.storage.getStorageGeneralUri(); if(isValidStorageUri(context, generalUri)) diff --git a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt index 5c5709fd..4dabccbb 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StatePlatform.kt @@ -797,6 +797,10 @@ class StatePlatform { return client.getChannelContents(channelUrl, type, ordering) ; } + fun peekChannelContents(baseClient: IPlatformClient, channelUrl: String, type: String?): List { + val client = _channelClientPool.getClientPooled(baseClient, Settings.instance.subscriptions.getSubscriptionsConcurrency()); + return client.peekChannelContents(channelUrl, type) ; + } fun getChannelLive(url: String, updateSubscriptions: Boolean = true): IPlatformChannel { val channel = getChannelClient(url).getChannel(url); diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SmartSubscriptionAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SmartSubscriptionAlgorithm.kt index 840fc288..dfed7fd2 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SmartSubscriptionAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SmartSubscriptionAlgorithm.kt @@ -1,5 +1,6 @@ package com.futo.platformplayer.subscription +import com.futo.platformplayer.Settings import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.platforms.js.JSClient import com.futo.platformplayer.getNowDiffHours @@ -7,6 +8,7 @@ import com.futo.platformplayer.logging.Logger import com.futo.platformplayer.models.Subscription import com.futo.platformplayer.states.StatePlatform import kotlinx.coroutines.CoroutineScope +import java.time.OffsetDateTime import java.util.concurrent.ForkJoinPool class SmartSubscriptionAlgorithm( @@ -70,18 +72,30 @@ class SmartSubscriptionAlgorithm( } else { val fetchTasks = mutableListOf(); val cacheTasks = mutableListOf(); + var peekTasks = mutableListOf(); for(task in clientTasks.second) { if (!task.fromCache && fetchTasks.size < limit) { fetchTasks.add(task); } else { - task.fromCache = true; - cacheTasks.add(task); + if(peekTasks.size < 100 && + Settings.instance.subscriptions.peekChannelContents && + (task.sub.lastPeekVideo.year < 1971 || task.sub.lastPeekVideo < task.sub.lastVideoUpdate) && + task.client.capabilities.hasPeekChannelContents && + task.client.getPeekChannelTypes().contains(task.type)) { + task.fromPeek = true; + task.fromCache = true; + peekTasks.add(task); + } + else { + task.fromCache = true; + cacheTasks.add(task); + } } } Logger.i(TAG, "Subscription Client Budget [${clientTasks.first.name}]: ${fetchTasks.size}/${limit}") - finalTasks.addAll(fetchTasks + cacheTasks); + finalTasks.addAll(fetchTasks + peekTasks + cacheTasks); } } @@ -115,6 +129,9 @@ class SmartSubscriptionAlgorithm( val lastUpdateHoursAgo = lastUpdate.getNowDiffHours(); val expectedHours = (interval * 24) - lastUpdateHoursAgo.toDouble(); - return (expectedHours * 100).toInt(); + if((type == ResultCapabilities.TYPE_MIXED || type == ResultCapabilities.TYPE_VIDEOS) && (sub.lastPeekVideo.year > 1970 && sub.lastPeekVideo > sub.lastVideoUpdate)) + return 0; + else + return (expectedHours * 100).toInt(); } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt index 9be47538..978500e0 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -24,6 +24,7 @@ import com.futo.platformplayer.states.StateCache import com.futo.platformplayer.states.StatePlatform import com.futo.platformplayer.states.StateSubscriptions import kotlinx.coroutines.CoroutineScope +import java.time.OffsetDateTime import java.util.concurrent.ExecutionException import java.util.concurrent.ForkJoinPool import java.util.concurrent.ForkJoinTask @@ -48,15 +49,17 @@ abstract class SubscriptionsTaskFetchAlgorithm( val tasksGrouped = tasks.groupBy { it.client } Logger.i(TAG, "Starting Subscriptions Fetch:\n" + - tasksGrouped.map { " ${it.key.name}: ${it.value.count { !it.fromCache }}, Cached(${it.value.count { it.fromCache } })" }.joinToString("\n")); + tasksGrouped.map { " ${it.key.name}: ${it.value.count { !it.fromCache }}, Cached(${it.value.count { it.fromCache } - it.value.count { it.fromPeek && it.fromCache }}), Peek(${it.value.count { it.fromPeek }})" }.joinToString("\n")); try { for(clientTasks in tasksGrouped) { - val clientTaskCount = clientTasks.value.filter { !it.fromCache }.size; - val clientCacheCount = clientTasks.value.size - clientTaskCount; + val clientTaskCount = clientTasks.value.count { !it.fromCache }; + val clientCacheCount = clientTasks.value.count { it.fromCache && !it.fromPeek }; + val clientPeekCount = clientTasks.value.count { it.fromPeek }; val limit = clientTasks.key.getSubscriptionRateLimit(); if(clientCacheCount > 0 && clientTaskCount > 0 && limit != null && clientTaskCount >= limit && StateApp.instance.contextOrNull?.let { it is MainActivity && it.isFragmentActive() } == true) { - UIDialogs.appToast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels (rqs). (${clientCacheCount} cached)"); + UIDialogs.appToast("[${clientTasks.key.name}] only updating ${clientTaskCount} most urgent channels (rqs). " + + "(${if(clientPeekCount > 0) "${clientPeekCount} peek, " else ""}${clientCacheCount} cached)"); } } @@ -135,8 +138,30 @@ abstract class SubscriptionsTaskFetchAlgorithm( for(task in tasks) { val forkTask = threadPool.submit { + if(task.fromPeek) { + try { + + val time = measureTimeMillis { + val peekResults = StatePlatform.instance.peekChannelContents(task.client, task.url, task.type); + val mostRecent = peekResults.firstOrNull(); + task.sub.lastPeekVideo = mostRecent?.datetime ?: OffsetDateTime.MIN; + task.sub.saveAsync(); + val cacheItems = peekResults.filter { it.datetime != null && it.datetime!! > task.sub.lastVideoUpdate }; + //Fix for current situation + for(item in cacheItems) { + if(item.author.thumbnail.isNullOrEmpty()) + item.author.thumbnail = task.sub.channel.thumbnail; + } + StateCache.instance.cacheContents(cacheItems, false); + } + Logger.i("StateSubscriptions", "Subscription peek [${task.sub.channel.name}]:${task.type} results in ${time}ms"); + } + catch(ex: Throwable) { + Logger.e(StateSubscriptions.TAG, "Subscription peek [${task.sub.channel.name}] failed", ex); + } + } synchronized(cachedChannels) { - if(task.fromCache) { + if(task.fromCache || task.fromPeek) { finished++; onProgress.emit(finished, forkTasks.size); if(cachedChannels.contains(task.url)) { @@ -218,6 +243,7 @@ abstract class SubscriptionsTaskFetchAlgorithm( val url: String, val type: String, var fromCache: Boolean = false, + var fromPeek: Boolean = false, var urgency: Int = 0 ); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fea3d914..caf59143 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -342,6 +342,8 @@ Fetch new results when the tab is opened (if no results yet, disabling is not recommended unless you have issues) Always reload from cache This is not recommended, but a possible workaround for some issues. + Peek Channel Contents + Peek channel contents if supported by plugin of rate-limited calls, may increase subscription reload time. Get answers to common questions Give feedback on the application Info @@ -487,6 +489,9 @@ Visibility Check for updates If a plugin should be checked for updates on startup + Allow Developer Submissions + Allows the developer to send data to their server, be careful as this might include sensitive data. + Make sure you trust the developer. They may gain access to sensitive data. Only enable this when you are trying to help the developer fix a bug. Rate-limit Settings related to rate-limiting this plugin\'s behavior Rate-limit Subscriptions diff --git a/app/src/unstable/assets/sources/patreon b/app/src/unstable/assets/sources/patreon index bc13b384..cee1fda4 160000 --- a/app/src/unstable/assets/sources/patreon +++ b/app/src/unstable/assets/sources/patreon @@ -1 +1 @@ -Subproject commit bc13b38411bdb8ad7c48d869ec9bc2068e671bd0 +Subproject commit cee1fda4e875a46315a9d4492e2e3b541d98f39f diff --git a/app/src/unstable/assets/sources/youtube b/app/src/unstable/assets/sources/youtube index bef199ba..940bb554 160000 --- a/app/src/unstable/assets/sources/youtube +++ b/app/src/unstable/assets/sources/youtube @@ -1 +1 @@ -Subproject commit bef199baa9df5cb3192c7a3f8baf8c57e9fbdaea +Subproject commit 940bb554d5fca5d8c3e2c6f501a74b7f03c9011b