plugin_result_to_stream only populated subtitle_tracks, not the subtitles
field Kotlin's Stream model actually reads, silently dropping plugin
subtitles. Also add pluginScraperSettingsUpdated and make repository
re-fetch (on manual refresh or app-start replay) carry over a scraper's
existing enabled/settings instead of resetting to manifest defaults.
Both skip=20 and skip=40 payloads are fully known as soon as
runDiscover completes (only local counters, no dependency on the
first page's contents), so there's no reason to wait for skip=20's
completeEffect before requesting skip=40. Emit both FetchDiscoverPage
effects together, tagged with the same DiscoverPaging generation.
The append-to-discover.results step only ran when initial_paging was
active (the internal multi-catalog merge sequence used on first load).
A manual, user-triggered discoverPageRequested dispatch (e.g. infinite
scroll load-more for the single currently-selected catalog) resets
initial_paging to empty in dispatch_discover_page, so its page results
landed only in discover.paging.items and were silently dropped from
discover.results. The recursive dispatch_next_initial_page call stays
gated on initial_paging so the merge-flow behavior is unchanged.
Kotlin's readDiscoverCatalogFilters effect now returns a contentTypes
list (distinct catalog types found across installed addons, e.g.
movie/series/anime), but the effect completion flows back through the
engine's own discover state before Kotlin ever sees it again. Added
content_types to DiscoverState and store it in the completion handler
for readDiscoverCatalogFilters, preserving it across a fresh discover
dispatch the same way catalogs/genres already are.
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.
Mirrors Nuvio's PluginManifest/PluginManifestScraper and the
parseJsonResults normalization from PluginRuntime.kt: validates
manifests (name/version/non-empty scrapers) and tolerantly parses a
plugin's raw getStreams() output (string-or-object url, blank
filtering) into typed results. Exposed via ffi.rs as
pluginManifestParse/pluginStreamResultsParse, and adds
ExecutePlugin/FetchPluginManifest to the effect vocabulary (not yet
wired to an engine completion flow — no-op for now).
A late-resolving refresh from a superseded home load could clobber
continue_watching state written by a newer load, since only the
readHomeBootstrap arm checked the generation counter.
Resolves trailer video/audio URLs through the engine's effect system
(watch-page config fetch, then player API), pairing the best AVC1
video with the best audio track. Uses the ANDROID_VR client instead
of plain ANDROID since ANDROID-issued CDN URLs are capped to serving
only the first ~8.8MB of a video regardless of request strategy.
Discover only ever fetched the first page (typically 20-50 items) of a
catalog and had no way to request more. Add a discoverPageRequested
action, a fetchDiscoverPage effect, and a dedicated DiscoverPaging
generation key so an in-flight "load more" fetch can't clobber a fresh
query (or vice versa) when the user changes catalog/genre mid-fetch.
Adds detail.failedAddons, populated from the fetchDetailStreams
completion payload, distinct from availableAddons so a permanently
failed addon (exhausted retries, bad response, etc.) can be shown to
the user as a failure rather than looking identical to "no streams".
Re-requesting streams for the same episode while a fetch is already
running used to bump the generation counter and orphan any addon
results still on the way, so a duplicate click could make a fully
valid addon result silently disappear. Now a duplicate request for the
same content is ignored while one is already in flight, and the
generation is only advanced when the request actually targets
different content.
detailStreamsAppended merged partial addon results as soon as they
arrived, gated only on is_loading_streams. Switching episodes quickly
resets that flag's data but not fast enough to stop a slower addon
response for the previous episode from landing in the new episode's
list. Tag the append with the request generation and drop it if the
active generation has since moved on.
is_loading only covered the results fetch, so the frontend had no way
to tell "catalogs still loading" apart from "no catalogs available",
causing a false empty state while addon catalogs were still in flight.
Episode auto-advance (playerLoadStreamsRequested) was fully decoupled
from progress saving: nothing in the core persisted the outgoing
episode's position/metadata before switching, so it relied entirely on
the host remembering to fire a separate savePlaybackProgressRequested
dispatch first. If the host missed that or the app was killed in the
gap between the two dispatches, the last watched position (and its
season/episode metadata) for the finished episode was lost.
playerLoadStreamsRequested now accepts an optional outgoingProgress
payload; when present and the video id is actually changing,
dispatch_load_streams emits a writePlaybackProgress effect for the
outgoing video in the same response as the stream-load effects, so a
single host dispatch covers both.
dispatch_load now also fires RefreshContinueWatching alongside
ReadHomeBootstrap, so continueWatching badges (new episode / up next)
can arrive and update state independently instead of only ever coming
from bootstrap.
Note: fluxa-desktop's readHomeBootstrap still computes badges inline
via refreshReleasedContinueWatching, so home load doesn't yet get
faster from this alone — it currently just computes badges twice.
Realizing the perf win needs a follow-up to drop that inline call and
rely solely on the parallel refresh effect.
Home billboard and category items kept raw addon trailer fields
(source/ytId) instead of the resolved YouTube URL that detail.rs
already produces via normalize_meta_trailer, so hero autoplay never
had a usable trailer URL to play.
Native on-device playback now stamps source:"local" on every PlaybackProgress
write, and Nuvio's sync already writes into the same progress bucket so it
gets source:"nuvio" from the desktop side. merge_continue_watching_lists_json
reads this tag (defaulting to "local" for pre-existing entries with no tag)
so source-of-truth pinning can target Local and Nuvio independently instead
of treating them as one inseparable group.
helpers.rs's addon trailer normalization and player.rs's prefetched-next-
episode cache were the only two production call sites left building JSON
object literals by hand in headless_engine; everything else under json!(
turned out to be either empty-array/object defaults or test fixtures
simulating platform effect results (which stay untyped on purpose, since
that's the actual external FFI boundary).
Per the robustness plan's §4. Found that types/resource.rs's MetaItem/
Stream/Video already existed as fully-typed wire structs (grep showed
zero callers anywhere in the crate — dead scaffolding, like the
FluxaEnv trait CLAUDE.md already flags) but weren't safe to wire in as
written: fields like MetaItem.id/name were required Strings with no
#[serde(default)], and none of the three had a #[serde(flatten)] extra
catch-all — exactly the "crucial constraint" the plan calls out, since
effect payloads echo these objects back to the platform and a dropped
unknown field would be silent data loss. Added default + flatten to
all three so they're actually safe to adopt.
New types/profile.rs adds Profile (id + flatten extra) and wires it
into the first AppAction field per the plan's priority order:
ProfileActivated { profile: Value } -> { profile: Profile }. Updated
library::dispatch_profile_activated and profile::activate to take the
typed value; `profile["id"]` string-indexing becomes `profile.id`.
profile::update_active and everything downstream (home state, effect
payloads) still work in terms of Value, converted once via
Profile::to_value() — this is a one-field migration, not a rewrite of
ProfileState's storage, so the wire format is provably unchanged (the
golden fixture from a prior commit asserts byte-identical dispatch
output and passes without modification).
Remaining actions with `profile`/`meta`/`streams`/`item: Value` fields
(~20 call sites) are intentionally left for follow-up, one at a time,
per the plan's own incremental sequencing.
Per the robustness plan's §9 (flagged as the single highest-leverage
perf change in the crate): headless_engine_dispatch_json used to clone
the entire EngineState before the reducer ran, clone it again after,
then StatePatch::diff did a deep PartialEq walk across all 16 domains
and cloned each changed one a third time — cost scaled with everything
the user has ever loaded (full catalogs, episode lists), not with what
the action actually touched.
Introduces Tracked<T> (src/headless_engine/state.rs): wraps each
EngineState domain field so any mutable access — a field write, a
whole-value replacement via DerefMut, a method call taking &mut —
flips a dirty bit automatically. Serialize/Deserialize delegate
straight to the wrapped value, so the wire format is byte-for-byte
unchanged (verified by the golden wire fixtures added in the prior
commit, which pass unmodified). EngineState::diff_dirty() replaces
StatePatch::diff(before, after): it clones only domains whose dirty
bit is set and clears it, so both the pre-dispatch full clone and the
post-dispatch PartialEq walk are gone entirely. The ~16 call sites that
did whole-domain replacement (`engine.state.detail = DetailState {
...}`) needed `*engine.state.detail = ...` instead — the compiler
catches any site the migration missed as a type error, so there's no
way for this rewrite to silently skip a domain the way a hand-added
mark_dirty() call could.
Also fixes headless_engine_snapshot_json, which used to serialize
while still holding the engines mutex; it now clones the state and
drops the lock before calling serde_json::to_string.
Adds benches/ (criterion, `--features bench` dev-only surface via a
new bench_targets re-export module mirroring the existing fuzz_targets
pattern): headless_engine_dispatch benches a NavigationRequested
dispatch against a 500-stream detail state (a domain the action
doesn't touch, so the win is directly visible), and stream_ranking
benches player_source_sidebar_plan_json grouping 500 streams. CI gets
a bench-check job compiling and smoke-running both (`--test`) without
paying for full timing runs on every push.
Per the robustness plan's §2/§3: introduce a pub(crate) CoreError enum
(BadInput/NotFound) plus a LogAndDiscard trait converting
Result<T, CoreError> back to the existing Option<T> FFI shims while
recording the failure. Rather than a push callback (no such pattern
exists anywhere in this codebase — checked jni.rs/uniffi.rs/ffi.rs and
the streaming engine, which all use poll-based Arc<Mutex<..>> state
instead), the sink is a bounded ring buffer drained via a new
`core.drainErrorLog` core_invoke method, a UniFFI
`drain_core_error_log_json` export, and a JNI
`drainCoreErrorLogJsonNative` extern — the host polls it (e.g. once per
dispatch tick) and forwards lines to logcat/desktop logs. This follows
the codebase's own established poll-not-push convention instead of
inventing a new one.
Wired into headless_engine's two real FFI boundary functions
(headless_engine_dispatch_json / headless_engine_complete_effect_json)
for both bad JSON and unknown-handle cases, and applied end-to-end
across the player_policy module (6 functions) as the first fully
migrated module, demonstrating the pattern for the remaining ~290
Option<String> functions to pick up incrementally.
Also hardens EffectResultInput per §3: effect_id is now a required
field (previously #[serde(default)] silently produced effect_id: ""
that matched no in-flight effect), and status is a closed EffectStatus
enum (Ok/Error/Cancelled) instead of a bare String — an unrecognized
status string now fails deserialization (and gets logged) instead of
comparing false against every "== \"ok\"" call site. Updated the ~39
`result.status == "ok"` / `!= "ok"` comparisons across headless_engine
to `result.status.is_ok()`; the wire strings themselves are unchanged
(serde's rename_all = "camelCase" already produces "ok"/"error").
Per the robustness plan's §8: a tests/wire/ directory holds one real
AppAction input per representative action family, each paired with a
checked-in golden DispatchResult captured from an actual dispatch (a
new #[cfg(test)] wire_fixtures_match_golden_dispatch_output test
compares against it, with UPDATE_WIRE_FIXTURES=1 to regenerate when a
change is intentional). Any future camelCase/field drift on the
dispatch/completeEffect wire now fails in this repo instead of surfacing
as a silent Android/desktop regression.
Also add fuzz/fuzz_targets/engine_dispatch.rs, feeding arbitrary bytes
into both headless_engine_dispatch_json and
headless_engine_complete_effect_json against one engine handle — this
is the one path in the crate that runs global engine-mutating logic
without a catch_unwind guard on the desktop call path, so it's the
highest-value fuzz target missing from fuzz/. Exposes the four
headless_engine entry points as `pub` (still unreachable outside the
crate except through the fuzzing-gated `fuzz_targets` re-export module)
following the same pattern already used for parse_manifest and the
content_identity helpers.
- Add fluxa-streaming-engine companion server (torrent start/stop,
ffmpeg transcode/probe, OAuth token exchange) for the web build
- Add SSRF guard restricting transcode/probe url param to http(s)
loopback hosts, blocking file:// and remote-host abuse via ffmpeg
- Add src/ffi.rs string-routed RPC dispatcher and wasm bindings
- Add headless engine profile module and several FFI surface
additions (scrobble plans, episode navigation, collections import/export)
- Remove dead runtime::msg/update scaffolding and addon_transport trait
- Remove fluxa_play.rs dev scratch binary (hardcoded scraper, not
part of the product)
- Reorder dv_rewrite.rs so implementation precedes its test module;
strip decorative section dividers
- Add docs/, fuzz/ targets, and a rewritten README
- Fix .gitignore to cover fluxa-streaming-engine/target
- Add headless engine with full action/effect dispatch (detail, player,
home, library, search, discover, calendar, offline, auth, sync)
- Add ffi.rs RPC dispatch table used by UniFFI (iOS) and WASM bindings
- Add wasm.rs binding surface (core_invoke + fluxa_core_version)
- Extend FluxaCore public API with tmdb, intro_segments, external_sync,
library_state, player_policy, watchlist, and search plan methods
- Fix torrent server race: replace AtomicBool + Mutex dual-state with
mutex-only source of truth for running status
- Document result_json drain contract in headless engine
- Remove decorative section dividers from core_api.rs (style)
- Remove unused normalize_skip_time function (dead code warning)
- Remove generated doc block from FluxaCore struct (style)
- Clarify app_state comment in ffi.rs (not legacy, used by Android JNI)