Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,38 @@ class PluginRuntimeDesktopTest {
assertEquals("1080p", results.single().quality)
assertEquals("Desktop Test", results.single().provider)
}

@Test
fun `desktop runtime provides working timers`() = runBlocking {
val results = PluginRuntime.executePlugin(
code = """
module.exports.getStreams = async function(tmdbId, mediaType) {
// setTimeout resolves and its callback runs
var fired = await new Promise(function(resolve) {
setTimeout(function(value) { resolve(value); }, 50, "fired");
});

// clearTimeout prevents the callback from running
var cancelled = "not-cancelled";
var id = setTimeout(function() { cancelled = "leaked"; }, 50);
clearTimeout(id);
await new Promise(function(resolve) { setTimeout(resolve, 120); });

return [{
title: fired + " " + cancelled,
url: "https://example.test/timers.mp4",
provider: "Timer Test"
}];
};
""".trimIndent(),
tmdbId = "603",
mediaType = "movie",
season = null,
episode = null,
scraperId = "desktop-timer-test",
)

assertEquals(1, results.size)
assertEquals("fired not-cancelled", results.single().title)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.nuvio.app.features.plugins.runtime.js.JsRuntime
import com.dokar.quickjs.binding.function
import com.nuvio.app.features.plugins.runtime.network.FetchBridge
import com.nuvio.app.features.plugins.runtime.network.UrlBridge
import com.nuvio.app.features.plugins.runtime.timers.TimerBridge
import com.nuvio.app.features.plugins.runtime.wasm.WasmBridge
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -133,6 +134,7 @@ internal object PluginRuntime {
val hostRegistry = HostApiRegistry().apply {
addModule(HostFunctions(scraperId) { deferred.complete(it) })
addModule(FetchBridge())
addModule(TimerBridge())
addModule(UrlBridge())
addModule(CryptoBridge())
addModule(WasmBridge())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ internal object JsBindings {

${fetchPolyfill()}
${abortControllerPolyfill()}
${timerPolyfill()}
${base64Polyfill()}
${urlPolyfill()}
${cryptoPolyfill()}
Expand Down Expand Up @@ -107,6 +108,68 @@ internal object JsBindings {
}
""".trimIndent()

// Backed by the __native_delay async binding (TimerBridge). Callbacks run as
// QuickJS jobs on the runtime's coroutine scope, so they are processed while
// the host awaits the plugin result and cancelled when the instance closes.
private fun timerPolyfill() = """
if (typeof setTimeout === 'undefined') {
(function() {
var timerSeq = 1;
var activeTimers = {};

function runCallback(fn, args) {
try {
if (typeof fn === 'function') fn.apply(undefined, args);
else if (typeof fn === 'string') (new Function(fn))();
} catch (e) {
console.error('timer callback error:', e && e.message ? e.message : e);
}
}

globalThis.setTimeout = function(fn, ms) {
var id = timerSeq++;
var args = Array.prototype.slice.call(arguments, 2);
activeTimers[id] = true;
__native_delay(Number(ms) || 0).then(function() {
if (!activeTimers[id]) return;
delete activeTimers[id];
runCallback(fn, args);
});
return id;
};

globalThis.clearTimeout = function(id) {
delete activeTimers[id];
};

globalThis.setInterval = function(fn, ms) {
var id = timerSeq++;
var args = Array.prototype.slice.call(arguments, 2);
activeTimers[id] = true;
function tick() {
__native_delay(Number(ms) || 0).then(function() {
if (!activeTimers[id]) return;
runCallback(fn, args);
tick();
});
}
tick();
return id;
};

globalThis.clearInterval = function(id) {
delete activeTimers[id];
};

globalThis.setImmediate = function(fn) {
var args = Array.prototype.slice.call(arguments, 1);
return globalThis.setTimeout.apply(undefined, [fn, 0].concat(args));
};
globalThis.clearImmediate = globalThis.clearTimeout;
})();
}
""".trimIndent()

private fun base64Polyfill() = """
if (typeof atob === 'undefined') {
globalThis.atob = function(input) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.nuvio.app.features.plugins.runtime.timers

import com.dokar.quickjs.QuickJs
import com.dokar.quickjs.binding.asyncFunction
import com.nuvio.app.features.plugins.runtime.host.HostModule
import kotlinx.coroutines.delay

// Plugins bundled for the mobile app rely on setTimeout/setInterval (mostly for
// fetch timeouts and retry backoff). QuickJS provides no timers by itself, so
// without this bridge those plugins throw ReferenceError and silently return
// zero streams. The JS-side polyfill lives in JsBindings.timerPolyfill().
private const val MAX_DELAY_MS = 60_000L

internal class TimerBridge : HostModule {
override fun register(runtime: QuickJs) {
runtime.asyncFunction("__native_delay") { args ->
val ms = (args.getOrNull(0) as? Number)?.toLong() ?: 0L
delay(ms.coerceIn(0L, MAX_DELAY_MS))
null
}
}
}