From da11474e8868cfd7df4d95b03bd249cb5abf8ac3 Mon Sep 17 00:00:00 2001 From: KhooLy <73142442+KhooLy@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:36:28 +0300 Subject: [PATCH] Wire plugin repository management into the headless engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/headless_engine/contracts.rs | 8 ++ src/headless_engine/mod.rs | 90 +++++++++++++++++++++- src/headless_engine/plugins.rs | 128 +++++++++++++++++++++++++++++++ src/headless_engine/state.rs | 7 ++ src/runtime/effects.rs | 4 + 5 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 src/headless_engine/plugins.rs diff --git a/src/headless_engine/contracts.rs b/src/headless_engine/contracts.rs index 5cfef63..1a5c70b 100644 --- a/src/headless_engine/contracts.rs +++ b/src/headless_engine/contracts.rs @@ -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, #[serde(skip_serializing_if = "Option::is_none")] + pub plugins: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub pending_effects: Option>, } diff --git a/src/headless_engine/mod.rs b/src/headless_engine/mod.rs index bbc1b16..ddef1a7 100644 --- a/src/headless_engine/mod.rs +++ b/src/headless_engine/mod.rs @@ -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("{}"); diff --git a/src/headless_engine/plugins.rs b/src/headless_engine/plugins.rs new file mode 100644 index 0000000..7b927f6 --- /dev/null +++ b/src/headless_engine/plugins.rs @@ -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 { + 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 { + 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 { + 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 { + 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![] +} diff --git a/src/headless_engine/state.rs b/src/headless_engine/state.rs index 4f0a67b..ebceb7c 100644 --- a/src/headless_engine/state.rs +++ b/src/headless_engine/state.rs @@ -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, pub(super) offline: Tracked, pub(super) trailer: Tracked, + pub(super) plugins: Tracked, pub(super) pending_effects: Tracked>, #[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(), } } diff --git a/src/runtime/effects.rs b/src/runtime/effects.rs index 353fbee..881e122 100644 --- a/src/runtime/effects.rs +++ b/src/runtime/effects.rs @@ -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,