mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-08-18 04:55:55 +00:00
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:
parent
e790a42dfa
commit
da11474e88
5 changed files with 235 additions and 2 deletions
|
|
@ -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>>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("{}");
|
||||
|
|
|
|||
128
src/headless_engine/plugins.rs
Normal file
128
src/headless_engine/plugins.rs
Normal 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![]
|
||||
}
|
||||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue