Supports fluxa-desktop's synthetic TMDB metadata addon: a static
manifest (catalog + meta resources only, no stream), a genre-name-to-id
catalog URL builder (popular/discover/search, skip mapped to TMDB's
page param), a rich meta mapper that prefers a resolved imdb id and
picks a title logo from TMDB's /images response, and an episode-list
mapper with still_path thumbnails.
merge_progress_meta_json only fell back to the existing value when the
incoming field was JSON null, but an empty string (what a progress
write produces when no metadata addon can resolve artwork) counted as
present and permanently overwrote the good value already on disk.
Treat empty strings the same as null so removing addons temporarily
can't permanently poison a continue-watching entry's artwork.
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.
The catalog picker showed "AddonName | Instance: Catalog Name" instead
of just the catalog name, which is redundant once the addon is already
selected via the type/catalog flow.
anime catalogs were collapsed into "series" during type normalization,
but discover_catalog_options_json compared the raw requested type
against the normalized catalog type, so selecting an "Anime" tab never
matched any catalog. Give anime its own bucket and normalize both
sides of the comparison the same way.
Derives skip segments from anime-skip.com's point-in-time timestamps
by pairing each recognized-type timestamp with the next one, unlike
AniSkip/IntroDB which already provide start/end intervals.
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.
select_next_episode_stream_json picked a stream by bingeGroup equality
(which Torrentio derives from the torrent infoHash alone, so every file
in a season-pack torrent shares one), falling back to the first stream
in the list with no title/filename check either way. A duplicate or
mis-scoped addon entry could win and play the wrong episode.
resolve_next_episode_json's released_only flag was backwards
(released_only || is_episode_released instead of !released_only ||
...), so the "raw next episode" and "released-only next episode"
call sites got each other's behavior. Also add debug timing around
torrent server teardown/bootstrap to help diagnose slow episode
transitions.
torrent_status_info_json (isPlayableEnough/statusKey/bufferProgress)
existed unreachable from any binding — no ffi.rs arm called it. It's
exactly the policy fluxa-desktop needs to decide when a torrent stream
is actually safe to hand to the player.
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.
Excluding a catalog entirely whenever it didn't declare genre as a supported
extra was too strict: once genre filtering actually started reaching the
core (previous commit), any user whose installed addons don't declare genre
support at all got zero catalogs and "No content found." Instead, only skip
sending the genre query param to catalogs that don't support it — they still
contribute their normal item list, which then goes through the existing
post-fetch genre filter in discover_sort_plan_json (checks each item's own
genres tag), so mismatched items still get filtered out without starving
the catalog list entirely.
Also switched to the canonical, case-insensitive catalog_supports_extra in
addon_protocol.rs instead of the ad-hoc case-sensitive duplicate added
earlier, matching the pattern already used by search_plan.rs.
Discover was sending genre=X to every installed catalog regardless of
whether its manifest declared genre as a supported extra. Addons commonly
ignore unsupported extras and return their default/unfiltered list, so
picking e.g. "Animation" pulled in unrelated items from catalogs (Action,
Sport, ...) that don't actually support genre filtering.
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.
lastEpisodeSeason and lastEpisodeNumber were only taken from the
incoming write when the video changed, with no fallback to the
existing value, same flaw as 54e5fff fixed for name/thumbnail. If
an auto-advanced episode's metadata was incomplete, this nulled out
season/number on the continue-watching record, which downstream
consumers then displayed as "Season 0".
lastEpisodeName and lastEpisodeThumbnail were only taken from the
incoming write when the video changed, with no fallback to the
existing value (unlike continueWatchingPoster/Background). If an
auto-advanced episode's metadata was incomplete, this nulled out a
previously-good title/thumbnail on the continue-watching card.
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.
"anime" is a virtual content-type bucket in Discover that doesn't map
to Stremio's native movie/series type field, so items from
anime-flagged catalogs were being dropped when their underlying type
didn't literally equal "anime". Treat it like the empty-filter case.
Extracts parse_addon_body (returns a ParsedAddonBody enum instead of
always serializing to a JSON string) so the existing three-hop path
(parseAddonResourceResult -> wrapAddonResourceResponse ->
resourceParsePlan, still used by subtitles.ts) and the new single-call
parseAndPlanAddonResource share the same parsing logic instead of
duplicating error-envelope construction.
Removes the now-redundant wrapAddonResourceResponse FFI method. Tests
assert the combined call produces byte-identical output to the old
three-step pipeline.
Pairs with fluxa-desktop's matching addonManifest.ts/fetchPlanning.ts
change.
apply_next_episode_badge only filtered out null, not empty string, so
an addon returning "thumbnail": "" for an episode would silently
overwrite a previously-cached good thumbnail and then read as missing,
forcing a fallback to series art until a real thumbnail reappeared.
Now matches the read-side str_field blank check.
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.
merge_continue_watching_lists_json and replace_external_continue_watching_json
accept optional sourceOfTruth/rankingMode params so a caller can pin a
specific provider or rank by episode number instead of always taking the
most recent savedAt. Defaults preserve prior behavior exactly.
Also tags Stremio's continue-watching items with reason:"stremio", matching
Trakt/Simkl/AniList, so it can participate in provider pinning and so stale
Stremio entries are actually evicted on re-sync instead of lingering forever.
Follow-up to c9b37c1's fallback/source-selection mode work, per the same
robustness-plan item: two more closed sets were being compared as string
literals in real decision logic.
player_scrobble.rs: should_enqueue_durable's action param was matched
against "pause"/"stop" inline; ScrobbleAction (Start/Pause/Stop/Unknown)
via an infallible From<&str> makes the set explicit without changing the
lenient fallback behavior (unrecognized actions still count as durable,
same as before).
profile_contract.rs: token_merge_plan_json's provider match arms
("trakt"/"mal"/"simkl"/"stremio"|"account"/_) become AuthProvider, same
infallible-fallback style so an unrecognized provider still no-ops
instead of hard-erroring.
Scope note: auth.rs's provider/mode fields and headless_engine's scrobble
action_name are NOT converted — neither is branched on in Rust, they're
pass-through values forwarded verbatim to platform-executed effects, so a
strict enum there would only add a rejection path for legitimate values
the platform side defines, not catch a real bug the way the two above do.
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 §7. The plan's main suggestion — a
core_methods! table macro generating ffi.rs's route_* functions — was
skipped: ffi.rs's ~115 methods have heterogeneous call shapes (0-arg,
single string arg, multi-field object arg, some routed straight to a
domain function, some with inline argument massaging first), and
CLAUDE.md already records the project's own assessed judgment that
consolidating the three FFI surfaces isn't worth the rewrite risk —
that reasoning applies just as much to a macro-generated version of
one of those three surfaces.
Implemented the plan's other, explicitly "cheap middle ground"
suggestion instead: tests/wire/core_invoke_methods.txt checks in the
current list of all 114 method names route() recognizes (extracted
from ffi.rs's match arms), and a new test
(ffi::tests::every_known_core_invoke_method_still_routes) calls
core_invoke(method, "{}") for each and asserts the error kind isn't
unknown_method. This doesn't validate each method's business logic —
only that the name is still wired to some router — but that's exactly
enough to turn a renamed or deleted method from a runtime "no such
method" surprise on a platform this repo can't see into a test failure
here. Verified it actually catches drift by temporarily adding a bogus
method name to the fixture and confirming the test fails.
core_capabilities_json generation from the method table (the plan's
second §7 bullet) doesn't apply here: core_contract.rs's
CoreCapabilitySet is a small hand-curated set of platform feature
flags (http/storage/auth/player/plugins/torrent/local_stream/
notifications), not a per-method availability list — there's no
natural 1:1 mapping from the 114 routed methods to those 8 flags to
generate from.
Per the robustness plan's §6: fallback_mode and source_selection_mode
were closed sets compared as string literals scattered across match
arms. Parse each once where the raw string enters real decision logic
instead of re-comparing spellings everywhere a typo could hide.
player_policy.rs: DvFallbackMode enum (Off/Auto/Dv8/ConvertDv81/Hdr10)
replaces the String field on DvProxyPlanRequest, deserialized directly
via serde (snake_case matches the existing wire strings exactly).
Caught a real bug in the process: the existing test suite already
covered "hdr10" as a value real requests send (matching the field's
own doc comment, which the string-comparison code had silently drifted
from — "hdr10" never hit any specific match arm, just fell through to
the same catch-all as "off"). Missing that variant broke a passing
test immediately, which is exactly the point of parsing at the
boundary: a typo/missed variant now fails loudly in one place instead
of silently in whichever comparison forgot about it.
stream_policy.rs: SourceSelectionMode enum (Regex/First/Manual) via an
infallible `From<&str>` (unlike DV mode, any unrecognized value should
keep behaving as "manual" like it always has, not become a hard
parse error) replaces the STREAM_SOURCE_MODE_* string constants in
select_stream_index/select_stream_index_values. Callers (jni.rs,
player_flow.rs) now convert with `.into()` at the same point they used
to pass the raw string through.
Scope notes for what's NOT done here, and why:
- Did not change EffectEnvelope.kind from String to EffectKind as §6
also suggests. EffectEnvelope is embedded in EngineState.pending_effects,
which round-trips through create_headless_engine's
serde_json::from_str(initial_json). Today an unrecognized kind string
in restored state is survivable (kind is a plain String; only the
from_str() lookup in complete_effect can fail, and it already drops
that one stale effect gracefully). Making kind a strict enum would
make one unrecognized effect kind fail deserialization of the entire
restored EngineState instead of just that effect — a resilience
regression across app-version upgrades that remove/rename a variant,
which runs directly against this plan's own goal. Left as String
pending a design for tolerant partial-state restore.
- scrobble action_name and auth provider/mode enums, and the ~80
json!({...}) payload literals in headless_engine, are deferred to a
follow-up pass — each is mechanical but independent of this change.
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 §10: stream_policy.rs's subtitle language
matching built a Regex from the user's language preference on every
call, and those calls run per-stream inside per-stream ranking loops.
Add a one-entry Mutex<Option<(String, Regex)>> cache keyed by the
normalized preference string — it changes once per settings edit, not
per stream — matching the plan's suggested approach.
external_sync.rs's IMDB id extraction compiled r"tt\d+" per history
item; it already exists as a shared OnceLock static
(content_identity::imdb_regex()), so just reuse that instead of adding
a second copy. watchlist_plan.rs's GitHub blob-URL rewrite compiled its
regex per call; moved to its own OnceLock static following the same
house pattern already used by imdb_regex()/year_regex().
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.