mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-07-26 20:02:13 +00:00
Add scraper settings-layout support to plugin runtime
Thread scraper_id and scraper_settings_json into execute_scraper, injecting globalThis.SCRAPER_ID/SCRAPER_SETTINGS ahead of the scraper's own code, matching Nuvio's JsBindings polyfill convention. Add get_settings_layout to run a scraper's onSettings() export and return its JSON field layout, with a matching UniFFI export.
This commit is contained in:
parent
f9000b4dbd
commit
945f8357f4
2 changed files with 256 additions and 3 deletions
|
|
@ -113,6 +113,8 @@ pub fn app_core_dispatch_json(handle: i64, action_json: String) -> String {
|
|||
pub fn execute_plugin_scraper(
|
||||
client: Box<dyn crate::plugin_runtime::PluginHttpClient>,
|
||||
code: String,
|
||||
scraper_id: String,
|
||||
scraper_settings_json: String,
|
||||
tmdb_id: String,
|
||||
media_type: String,
|
||||
season: Option<i32>,
|
||||
|
|
@ -122,6 +124,8 @@ pub fn execute_plugin_scraper(
|
|||
crate::plugin_runtime::execute_scraper(
|
||||
Arc::from(client),
|
||||
code,
|
||||
scraper_id,
|
||||
scraper_settings_json,
|
||||
tmdb_id,
|
||||
media_type,
|
||||
season,
|
||||
|
|
@ -130,3 +134,11 @@ pub fn execute_plugin_scraper(
|
|||
.unwrap_or_else(|_| "[]".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "plugin-js-engine")]
|
||||
#[uniffi::export]
|
||||
pub fn get_plugin_scraper_settings_layout(code: String, scraper_id: String) -> String {
|
||||
guard("[]".to_string(), || {
|
||||
crate::plugin_runtime::get_settings_layout(code, scraper_id)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ pub trait PluginHttpClient: Send + Sync {
|
|||
pub fn execute_scraper(
|
||||
client: Arc<dyn PluginHttpClient>,
|
||||
code: String,
|
||||
scraper_id: String,
|
||||
scraper_settings_json: String,
|
||||
tmdb_id: String,
|
||||
media_type: String,
|
||||
season: Option<i32>,
|
||||
|
|
@ -55,16 +57,176 @@ pub fn execute_scraper(
|
|||
local.block_on(&rt, async move {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(PLUGIN_TIMEOUT_SECS),
|
||||
run(client, code, tmdb_id, media_type, season, episode),
|
||||
run(
|
||||
client,
|
||||
code,
|
||||
scraper_id,
|
||||
scraper_settings_json,
|
||||
tmdb_id,
|
||||
media_type,
|
||||
season,
|
||||
episode,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("plugin timed out".to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_settings_layout(code: String, scraper_id: String) -> String {
|
||||
let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
|
||||
Ok(rt) => rt,
|
||||
Err(_) => return "[]".to_string(),
|
||||
};
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local.block_on(&rt, async move {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(PLUGIN_TIMEOUT_SECS),
|
||||
run_settings_layout(code, scraper_id),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| "[]".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
async fn run_settings_layout(code: String, scraper_id: String) -> String {
|
||||
let qjs_rt = match AsyncRuntime::new() {
|
||||
Ok(rt) => rt,
|
||||
Err(_) => return "[]".to_string(),
|
||||
};
|
||||
qjs_rt.set_memory_limit(PLUGIN_MEMORY_LIMIT).await;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(PLUGIN_TIMEOUT_SECS);
|
||||
qjs_rt
|
||||
.set_interrupt_handler(Some(Box::new(move || std::time::Instant::now() > deadline)))
|
||||
.await;
|
||||
tokio::task::spawn_local(qjs_rt.drive());
|
||||
let ctx = match AsyncContext::full(&qjs_rt).await {
|
||||
Ok(ctx) => ctx,
|
||||
Err(_) => return "[]".to_string(),
|
||||
};
|
||||
|
||||
let captured: Arc<Mutex<Option<String>>> = Default::default();
|
||||
let captured_clone = captured.clone();
|
||||
let dom = DomBridge::new();
|
||||
let client: Arc<dyn PluginHttpClient> = Arc::new(NoopHttpClient);
|
||||
|
||||
let eval_result: rquickjs::Result<()> = ctx
|
||||
.async_with(async |ctx| {
|
||||
register_host_functions(&ctx, &dom, client)?;
|
||||
|
||||
let scraper_id_arg = serde_json::to_string(&scraper_id).unwrap_or_else(|_| "\"\"".into());
|
||||
|
||||
let script = format!(
|
||||
r#"
|
||||
globalThis.global = globalThis;
|
||||
globalThis.window = globalThis;
|
||||
globalThis.SCRAPER_ID = {scraper_id_arg};
|
||||
globalThis.SCRAPER_SETTINGS = {{}};
|
||||
|
||||
function fetch(url, options) {{
|
||||
options = options || {{}};
|
||||
var method = options.method || 'GET';
|
||||
var headersJson = JSON.stringify(options.headers || {{}});
|
||||
var body = options.body === undefined || options.body === null ? null : String(options.body);
|
||||
var followRedirects = options.redirect !== 'manual';
|
||||
var raw = __native_fetch(url, method, headersJson, body, followRedirects);
|
||||
var parsed = JSON.parse(raw);
|
||||
return Promise.resolve({{
|
||||
ok: parsed.ok,
|
||||
status: parsed.status,
|
||||
text: function() {{ return Promise.resolve(parsed.body); }},
|
||||
json: function() {{
|
||||
try {{ return Promise.resolve(JSON.parse(parsed.body)); }}
|
||||
catch (e) {{ return Promise.resolve(null); }}
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
|
||||
{base64_polyfill}
|
||||
{text_encoder_polyfill}
|
||||
{crypto_polyfill}
|
||||
{cheerio_polyfill}
|
||||
|
||||
var require = function(name) {{
|
||||
if (name.indexOf('cheerio') !== -1) return cheerio;
|
||||
if (name === 'crypto-js') return CryptoJS;
|
||||
throw new Error('module not available: ' + name);
|
||||
}};
|
||||
|
||||
var module = {{ exports: {{}} }};
|
||||
var exports = module.exports;
|
||||
(function() {{
|
||||
{code}
|
||||
}})();
|
||||
|
||||
(async function() {{
|
||||
try {{
|
||||
var onSettings = module.exports.onSettings || globalThis.onSettings;
|
||||
if (!onSettings) {{
|
||||
__capture_result(JSON.stringify([]));
|
||||
return;
|
||||
}}
|
||||
var layout = await onSettings();
|
||||
__capture_result(JSON.stringify(layout || []));
|
||||
}} catch (e) {{
|
||||
__capture_result(JSON.stringify([]));
|
||||
}}
|
||||
}})();
|
||||
"#,
|
||||
base64_polyfill = BASE64_POLYFILL,
|
||||
text_encoder_polyfill = TEXT_ENCODER_POLYFILL,
|
||||
crypto_polyfill = CRYPTO_POLYFILL,
|
||||
cheerio_polyfill = CHEERIO_POLYFILL,
|
||||
code = code,
|
||||
);
|
||||
|
||||
ctx.globals().set(
|
||||
"__capture_result",
|
||||
Function::new(ctx.clone(), move |s: String| {
|
||||
*captured_clone.lock().expect("capture lock poisoned") = Some(s);
|
||||
})?,
|
||||
)?;
|
||||
|
||||
ctx.eval::<(), _>(script).catch(&ctx).map_err(|e| {
|
||||
rquickjs::Error::new_from_js_message("plugin", "js", e.to_string())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
|
||||
if eval_result.is_err() {
|
||||
return "[]".to_string();
|
||||
}
|
||||
qjs_rt.idle().await;
|
||||
|
||||
let result = captured
|
||||
.lock()
|
||||
.expect("capture lock poisoned")
|
||||
.take()
|
||||
.unwrap_or_else(|| "[]".to_string());
|
||||
result
|
||||
}
|
||||
|
||||
struct NoopHttpClient;
|
||||
|
||||
impl PluginHttpClient for NoopHttpClient {
|
||||
fn fetch(&self, _request: PluginHttpRequest) -> PluginHttpResponse {
|
||||
PluginHttpResponse {
|
||||
status: 0,
|
||||
headers: HashMap::new(),
|
||||
body: String::new(),
|
||||
ok: false,
|
||||
error: Some("network disabled in settings-layout evaluation".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(
|
||||
client: Arc<dyn PluginHttpClient>,
|
||||
code: String,
|
||||
scraper_id: String,
|
||||
scraper_settings_json: String,
|
||||
tmdb_id: String,
|
||||
media_type: String,
|
||||
season: Option<i32>,
|
||||
|
|
@ -87,6 +249,12 @@ async fn run(
|
|||
.async_with(async |ctx| {
|
||||
register_host_functions(&ctx, &dom, client)?;
|
||||
|
||||
let scraper_id_arg = serde_json::to_string(&scraper_id).unwrap_or_else(|_| "\"\"".into());
|
||||
let scraper_settings_arg = if scraper_settings_json.trim().is_empty() {
|
||||
"{}".to_string()
|
||||
} else {
|
||||
scraper_settings_json.clone()
|
||||
};
|
||||
let tmdb_id_arg = serde_json::to_string(&tmdb_id).unwrap_or_else(|_| "\"\"".into());
|
||||
let media_type_arg =
|
||||
serde_json::to_string(&media_type).unwrap_or_else(|_| "\"movie\"".into());
|
||||
|
|
@ -97,6 +265,8 @@ async fn run(
|
|||
r#"
|
||||
globalThis.global = globalThis;
|
||||
globalThis.window = globalThis;
|
||||
globalThis.SCRAPER_ID = {scraper_id_arg};
|
||||
globalThis.SCRAPER_SETTINGS = {scraper_settings_arg};
|
||||
|
||||
function fetch(url, options) {{
|
||||
options = options || {{}};
|
||||
|
|
@ -1171,8 +1341,17 @@ mod tests {
|
|||
}
|
||||
|
||||
fn run_scraper(code: &str, tmdb_id: &str, media_type: &str) -> String {
|
||||
execute_scraper(mock_client(), code.to_string(), tmdb_id.to_string(), media_type.to_string(), None, None)
|
||||
.unwrap()
|
||||
execute_scraper(
|
||||
mock_client(),
|
||||
code.to_string(),
|
||||
"test-scraper".to_string(),
|
||||
"{}".to_string(),
|
||||
tmdb_id.to_string(),
|
||||
media_type.to_string(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1311,4 +1490,66 @@ mod tests {
|
|||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed[0]["title"], "true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scraper_id_and_settings_are_visible_as_globals() {
|
||||
let code = r#"
|
||||
module.exports.getStreams = async function() {
|
||||
return [{ title: SCRAPER_ID + ':' + SCRAPER_SETTINGS.quality, url: 'https://example.com/x' }];
|
||||
};
|
||||
"#;
|
||||
let result = execute_scraper(
|
||||
mock_client(),
|
||||
code.to_string(),
|
||||
"my-scraper".to_string(),
|
||||
r#"{"quality":"1080p"}"#.to_string(),
|
||||
"1".to_string(),
|
||||
"movie".to_string(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed[0]["title"], "my-scraper:1080p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_layout_returns_declared_field_layout() {
|
||||
let code = r#"
|
||||
module.exports.onSettings = async function() {
|
||||
return [
|
||||
{ type: 'header', label: 'General' },
|
||||
{ key: 'apiKey', type: 'text', label: 'API Key', isPassword: true },
|
||||
{
|
||||
key: 'quality',
|
||||
type: 'select',
|
||||
label: 'Preferred Quality',
|
||||
defaultValue: '1080p',
|
||||
options: [
|
||||
{ label: '720p', value: '720p' },
|
||||
{ label: '1080p', value: '1080p' }
|
||||
]
|
||||
},
|
||||
{ key: 'enabled', type: 'toggle', label: 'Enable Feature', defaultValue: true }
|
||||
];
|
||||
};
|
||||
"#;
|
||||
|
||||
let result = get_settings_layout(code.to_string(), "my-scraper".to_string());
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed.as_array().unwrap().len(), 4);
|
||||
assert_eq!(parsed[1]["key"], "apiKey");
|
||||
assert_eq!(parsed[2]["options"][1]["value"], "1080p");
|
||||
assert_eq!(parsed[3]["defaultValue"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_layout_defaults_to_empty_array_when_undefined() {
|
||||
let code = r#"
|
||||
module.exports.getStreams = async function() { return []; };
|
||||
"#;
|
||||
|
||||
let result = get_settings_layout(code.to_string(), "my-scraper".to_string());
|
||||
assert_eq!(result, "[]");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue