Wire plugin repository management into the headless engine

Adds a plugins domain to the headless engine (mirrors settings.rs/
offline.rs): pluginRepositoryAddRequested dispatches FetchPluginManifest,
completion upserts the repository and replaces its scrapers; remove and
per-scraper enable/disable are pure local edits. Reuses the existing
addon_store::normalize_plugin_repository_url helper for the URL.

Also fixes EffectKind::from_str missing the executePlugin/
fetchPluginManifest arms added in the previous commit (silently made
those effects undispatchable — completion always fell through as an
unrecognized type) and backfills the as_str/from_str roundtrip test's
variant list, which is supposed to catch exactly that.

ExecutePlugin (actually running scraper JS) is still a no-op in the
engine — that's the native QuickJS runtime work from the desktop-side
spike, not yet connected here.
This commit is contained in:
KhooLy 2026-07-13 16:36:28 +03:00
parent e790a42dfa
commit da11474e88
5 changed files with 235 additions and 2 deletions

View file

@ -325,6 +325,12 @@ pub(super) enum AppAction {
TrailerResolveRequested { request_id: String, video_id: String },
#[serde(rename = "trailerPrewarmRequested")]
TrailerPrewarmRequested,
#[serde(rename = "pluginRepositoryAddRequested")]
PluginRepositoryAddRequested { manifest_url: String },
#[serde(rename = "pluginRepositoryRemoveRequested")]
PluginRepositoryRemoveRequested { manifest_url: String },
#[serde(rename = "pluginScraperToggled")]
PluginScraperToggled { scraper_id: String, enabled: bool },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
@ -399,5 +405,7 @@ pub(super) struct StatePatch {
#[serde(skip_serializing_if = "Option::is_none")]
pub trailer: Option<super::trailer::TrailerState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub plugins: Option<super::plugins::PluginsState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pending_effects: Option<Vec<EffectEnvelope>>,
}

View file

@ -10,6 +10,7 @@ mod library;
mod navigation;
mod offline;
mod player;
mod plugins;
mod profile;
mod search;
mod settings;
@ -551,6 +552,15 @@ impl HeadlessEngine {
trailer::dispatch_resolve(self, request_id, video_id)
}
AppAction::TrailerPrewarmRequested => trailer::dispatch_prewarm(self),
AppAction::PluginRepositoryAddRequested { manifest_url } => {
plugins::dispatch_add_repository(self, manifest_url)
}
AppAction::PluginRepositoryRemoveRequested { manifest_url } => {
plugins::dispatch_remove_repository(self, manifest_url)
}
AppAction::PluginScraperToggled { scraper_id, enabled } => {
plugins::dispatch_toggle_scraper(self, scraper_id, enabled)
}
}
}
@ -650,11 +660,12 @@ impl HeadlessEngine {
trailer::complete(self, effect_type, generation, &effect, &result)
}
EffectKind::FetchPluginManifest => plugins::complete(self, generation, &result),
EffectKind::UpdateCalendarWidget
| EffectKind::NotifyReleasedEpisodes
| EffectKind::ReplaceExternalContinueWatching
| EffectKind::ExecutePlugin
| EffectKind::FetchPluginManifest => vec![],
| EffectKind::ExecutePlugin => vec![],
}
}
@ -1668,6 +1679,81 @@ mod tests {
assert!(destroy_headless_engine(handle));
}
#[test]
fn plugin_repository_add_completion_populates_repositories_and_scrapers() {
let handle = create_headless_engine("{}");
let requested: Value = serde_json::from_str(
&headless_engine_dispatch_json(
handle,
r#"{"type":"pluginRepositoryAddRequested","manifestUrl":"https://example.com/manifest.json"}"#,
)
.unwrap(),
)
.unwrap();
assert_eq!(requested["effects"][0]["type"], "fetchPluginManifest");
assert_eq!(
requested["state"]["plugins"]["addingRepositoryUrl"],
"https://example.com/manifest.json"
);
let completed: Value = serde_json::from_str(
&headless_engine_complete_effect_json(
handle,
&json!({
"effectId": requested["effects"][0]["id"].as_str().unwrap(),
"status": "ok",
"value": {
"manifestUrl": "https://example.com/manifest.json",
"manifest": {
"name": "Phisher's Repo",
"version": "1.0.0",
"scrapers": [
{"id": "MoviesDrive", "name": "MoviesDrive", "version": "1.1.1", "filename": "src/providers/moviesdrive.js"}
]
}
}
})
.to_string(),
)
.unwrap(),
)
.unwrap();
assert_eq!(completed["state"]["plugins"]["addingRepositoryUrl"], Value::Null);
assert_eq!(
completed["state"]["plugins"]["repositories"][0]["name"],
"Phisher's Repo"
);
assert_eq!(
completed["state"]["plugins"]["scrapers"][0]["repositoryUrl"],
"https://example.com/manifest.json"
);
let removed: Value = serde_json::from_str(
&headless_engine_dispatch_json(
handle,
r#"{"type":"pluginRepositoryRemoveRequested","manifestUrl":"https://example.com/manifest.json"}"#,
)
.unwrap(),
)
.unwrap();
assert_eq!(
removed["state"]["plugins"]["repositories"]
.as_array()
.unwrap()
.len(),
0
);
assert_eq!(
removed["state"]["plugins"]["scrapers"]
.as_array()
.unwrap()
.len(),
0
);
assert!(destroy_headless_engine(handle));
}
#[test]
fn calendar_completion_plans_os_side_effects_in_core() {
let handle = create_headless_engine("{}");

View file

@ -0,0 +1,128 @@
use super::helpers::{normalize_error, upsert_by_key};
use super::state::GenerationKey;
use super::{EffectResultInput, HeadlessEngine};
use crate::addon_store::normalize_plugin_repository_url;
use crate::runtime::{EffectEnvelope, EffectKind};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", default)]
pub(super) struct PluginsState {
repositories: Value,
scrapers: Value,
adding_repository_url: Value,
error: Value,
}
impl Default for PluginsState {
fn default() -> Self {
Self {
repositories: json!([]),
scrapers: json!([]),
adding_repository_url: Value::Null,
error: Value::Null,
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct FetchPluginManifestPayload {
manifest_url: String,
}
pub(super) fn dispatch_add_repository(
engine: &mut HeadlessEngine,
manifest_url: String,
) -> Vec<EffectEnvelope> {
let manifest_url = normalize_plugin_repository_url(&manifest_url);
let generation = engine.bump_generation(GenerationKey::Plugins);
engine.state.plugins.adding_repository_url = Value::String(manifest_url.clone());
engine.state.plugins.error = Value::Null;
vec![engine.effect(
EffectKind::FetchPluginManifest,
generation,
FetchPluginManifestPayload { manifest_url },
)]
}
pub(super) fn dispatch_remove_repository(
engine: &mut HeadlessEngine,
manifest_url: String,
) -> Vec<EffectEnvelope> {
let manifest_url = normalize_plugin_repository_url(&manifest_url);
if let Some(items) = engine.state.plugins.repositories.as_array_mut() {
items.retain(|repo| repo["manifestUrl"].as_str() != Some(manifest_url.as_str()));
}
if let Some(items) = engine.state.plugins.scrapers.as_array_mut() {
items.retain(|scraper| scraper["repositoryUrl"].as_str() != Some(manifest_url.as_str()));
}
vec![]
}
pub(super) fn dispatch_toggle_scraper(
engine: &mut HeadlessEngine,
scraper_id: String,
enabled: bool,
) -> Vec<EffectEnvelope> {
if let Some(items) = engine.state.plugins.scrapers.as_array_mut() {
if let Some(scraper) = items
.iter_mut()
.find(|scraper| scraper["id"].as_str() == Some(scraper_id.as_str()))
{
scraper["enabled"] = Value::Bool(enabled);
}
}
vec![]
}
pub(super) fn complete(
engine: &mut HeadlessEngine,
generation: u64,
result: &EffectResultInput,
) -> Vec<EffectEnvelope> {
if generation != engine.state.runtime.get(GenerationKey::Plugins) {
return vec![];
}
engine.state.plugins.adding_repository_url = Value::Null;
if !result.status.is_ok() {
engine.state.plugins.error = normalize_error(result.error.clone());
return vec![];
}
let manifest_url = result.value["manifestUrl"].as_str().unwrap_or_default();
let manifest = &result.value["manifest"];
if manifest_url.is_empty() || !manifest.is_object() {
engine.state.plugins.error = normalize_error(Value::Null);
return vec![];
}
let scrapers = manifest["scrapers"].as_array().cloned().unwrap_or_default();
let repository_entry = json!({
"manifestUrl": manifest_url,
"name": manifest["name"],
"description": manifest["description"],
"version": manifest["version"],
"scraperCount": scrapers.len(),
});
upsert_by_key(
&mut engine.state.plugins.repositories,
"manifestUrl",
manifest_url,
repository_entry,
);
if let Some(items) = engine.state.plugins.scrapers.as_array_mut() {
items.retain(|scraper| scraper["repositoryUrl"].as_str() != Some(manifest_url));
}
for mut scraper in scrapers {
scraper["repositoryUrl"] = Value::String(manifest_url.to_string());
if let Some(items) = engine.state.plugins.scrapers.as_array_mut() {
items.push(scraper);
}
}
engine.state.plugins.error = Value::Null;
vec![]
}

View file

@ -8,6 +8,7 @@ use super::library::LibraryState;
use super::navigation::NavigationState;
use super::offline::OfflineState;
use super::player::PlayerState;
use super::plugins::PluginsState;
use super::profile::ProfileState;
use super::search::SearchState;
use super::settings::SettingsState;
@ -101,6 +102,7 @@ pub(super) enum GenerationKey {
PlaybackPrep,
Intro,
Trailer,
Plugins,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
@ -124,6 +126,7 @@ pub(super) struct RuntimeGenerations {
playback_prep_generation: u64,
intro_generation: u64,
trailer_generation: u64,
plugins_generation: u64,
}
impl RuntimeGenerations {
@ -147,6 +150,7 @@ impl RuntimeGenerations {
GenerationKey::PlaybackPrep => self.playback_prep_generation,
GenerationKey::Intro => self.intro_generation,
GenerationKey::Trailer => self.trailer_generation,
GenerationKey::Plugins => self.plugins_generation,
}
}
@ -170,6 +174,7 @@ impl RuntimeGenerations {
GenerationKey::PlaybackPrep => &mut self.playback_prep_generation,
GenerationKey::Intro => &mut self.intro_generation,
GenerationKey::Trailer => &mut self.trailer_generation,
GenerationKey::Plugins => &mut self.plugins_generation,
};
*slot = slot.saturating_add(1);
*slot
@ -195,6 +200,7 @@ pub(super) struct EngineState {
pub(super) lookup: Tracked<LookupState>,
pub(super) offline: Tracked<OfflineState>,
pub(super) trailer: Tracked<TrailerState>,
pub(super) plugins: Tracked<PluginsState>,
pub(super) pending_effects: Tracked<Vec<EffectEnvelope>>,
#[serde(rename = "_runtime")]
pub(super) runtime: RuntimeGenerations,
@ -219,6 +225,7 @@ impl EngineState {
lookup: self.lookup.take_if_dirty(),
offline: self.offline.take_if_dirty(),
trailer: self.trailer.take_if_dirty(),
plugins: self.plugins.take_if_dirty(),
pending_effects: self.pending_effects.take_if_dirty(),
}
}

View file

@ -119,6 +119,7 @@ impl EffectKind {
"enqueueOfflineDownload" => EffectKind::EnqueueOfflineDownload,
"enqueueTraktScrobble" => EffectKind::EnqueueTraktScrobble,
"exchangeAuthCode" => EffectKind::ExchangeAuthCode,
"executePlugin" => EffectKind::ExecutePlugin,
"fetchAddonManifest" => EffectKind::FetchAddonManifest,
"fetchAddonResource" => EffectKind::FetchAddonResource,
"fetchCatalogPage" => EffectKind::FetchCatalogPage,
@ -128,6 +129,7 @@ impl EffectKind {
"fetchIntroSegments" => EffectKind::FetchIntroSegments,
"fetchMetaDetail" => EffectKind::FetchMetaDetail,
"fetchMetaDetailLookup" => EffectKind::FetchMetaDetailLookup,
"fetchPluginManifest" => EffectKind::FetchPluginManifest,
"fetchSeasonEpisodes" => EffectKind::FetchSeasonEpisodes,
"fetchSubtitles" => EffectKind::FetchSubtitles,
"fetchYoutubeTrailerPlayer" => EffectKind::FetchYoutubeTrailerPlayer,
@ -222,6 +224,7 @@ mod tests {
EffectKind::EnqueueOfflineDownload,
EffectKind::EnqueueTraktScrobble,
EffectKind::ExchangeAuthCode,
EffectKind::ExecutePlugin,
EffectKind::FetchAddonManifest,
EffectKind::FetchAddonResource,
EffectKind::FetchCatalogPage,
@ -231,6 +234,7 @@ mod tests {
EffectKind::FetchIntroSegments,
EffectKind::FetchMetaDetail,
EffectKind::FetchMetaDetailLookup,
EffectKind::FetchPluginManifest,
EffectKind::FetchSeasonEpisodes,
EffectKind::FetchSubtitles,
EffectKind::FetchYoutubeTrailerPlayer,