Without a visitor_data header, YouTube strips the url/cipher from every
adaptive format and only serves the lowest-quality progressive stream
(itag 18, ~360p). Fetch a real watch page once (cached, 3h TTL) to grab
INNERTUBE_API_KEY/VISITOR_DATA and send it as X-Goog-Visitor-Id, and add
the ANDROID_VR client ahead of ANDROID/IOS, matching the approach used by
other Stremio-ecosystem clients (Nuvio) to unlock full adaptive quality.
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.
Extends resolve_youtube_trailer_json to also extract caption tracks and
distinguish geo-blocked vs. failed outcomes, then exposes it over JNI
(Java_..._FluxaStreamingNative_resolveYoutubeTrailerJsonNative) so the
Android app's TrailerResolver can delegate to this instead of keeping
its own separate innertube client in sync.
Adds a minimal innertube client (ANDROID primary, IOS fallback) that
POSTs to /youtubei/v1/player and extracts a direct progressive mp4 URL
from streamingData.formats, with a disk cache (6h TTL) keyed by video
id. Lets the desktop shell play a muted/looping trailer through a
plain <video> element instead of embedding YouTube's own web player.
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 CLAUDE.md's rule to verify against fluxa-desktop/src-tauri call
sites before touching FluxaCore: the plan's premise for this section
no longer holds. dispatchAction/completeEffect/getSnapshot in
fluxa-desktop's src/core/engine.ts already go through dedicated Tauri
commands (engine_dispatch/engine_complete_effect/engine_snapshot) that
call FluxaCore::* directly in Rust, not through core_invoke. FluxaCore
itself has also grown from the "8 methods" this doc assumed to 21
(cast/AirPlay/Chromecast/Roku helpers), all with real call sites
verified via grep. The remaining core_invoke Tauri command is a
generic fallback for infrequent methods where a dedicated command
per method would be boilerplate with no measurable win, since Tauri
IPC serializes as JSON at the JS<->Rust boundary either way. No code
change needed; also corrected the stale "8 methods" claim in
CLAUDE.md's core_api description to reflect the current count.
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.
Add warn-level unsafe_op_in_unsafe_fn / unwrap_used / expect_used /
indexing_slicing / panic clippy lints (test code exempted via cfg_attr),
per the robustness plan's ratchet-to-deny strategy. Add a CI workflow
covering workspace test/clippy/fmt plus a wasm-only cargo check. Run
`cargo fmt` to clear pre-existing formatting drift so the fmt gate starts
clean, and drop a now-unnecessary `mut` flagged by clippy.
b0bb52a added real HLS segment-level RPU conversion (fluxa-streaming-engine's
OkHttp interceptor) for P7 streams with a DV decoder, but left behind a test
that still expected the old manifest-only passthrough. Split it into two
cases: decoder present -> hls_rpu_convert, decoder absent -> manifest_handled.
resolve_transport_url_json and resolve_feed_option_genre_json feed
opt_json() at the FFI boundary, which requires valid JSON — a bare
URL/genre string isn't, so resolveTransportUrl/resolveFeedOptionGenre
would fail from the frontend whenever they had a result. Finishes an
in-progress fix that JSON-encoded the transport-url return but missed
the early-return genre branch, and updates home_ranking.rs's direct
(non-FFI) callers to unwrap the now JSON-encoded string before
embedding it in a json!() literal, since json!() already does its own
string encoding.
start_torrent_server is a process-wide singleton that fully tears
down and recreates the engine on every play. A stop invoked for an
old session could race a fast replay's start and land after the new
session was already up, silently killing the just-started server and
leaving mpv with a dead socket (MPV_ERROR_LOADING_FAILED / "Loading
failed" on the same torrent). Each server generation is now tagged;
stop is a no-op unless it targets the currently active generation.
.env.example lists the Trakt/Simkl/MAL client id/secret vars companion_server
and oauth_proxy read at runtime; ignore fluxa-streaming-engine/.env so a real
copy never gets committed.
Bind the listener only after the torrent session is up (with an 18s init
timeout) instead of before, wrap add_lock/api_add_torrent in a 45s timeout
so a stuck peer/tracker can't hang requests forever, and stop stat-only
polls from triggering ensure_torrent so they can't contend with the real
stream request. Also track bytes actually streamed to the client
(CountingReader) so the preload/stat readiness check reflects playback
progress past a seek, not just bytes written to disk.
The methods added for the desktop migration (detectAnimePlayback,
anilistEntriesToSync, mergeLibraryItemsById, tmdb people helpers) now
take plain nested JSON instead of stringified-JSON-in-JSON, so callers
encode once and the router hands Values straight to the domain
functions. The AniList mapping deserializes into AnilistEntry/
AnilistMedia serde structs instead of spelunking Value paths.
Ports pure logic the desktop shell was keeping in TypeScript: the anime
playback confidence heuristic, AniList entry classification into
watchlist/completed/dropped/watching buckets with watched keys and
progress records, an id-keyed library item merge, and the TMDB
find/credits URL planning plus credits-to-people-images mapping.
tmdb_language now also accepts the desktop pref values english_us and
tr_tr.
Maps Stremio datastore library items to watchlist entries (content-level,
skipping removed/temp and per-episode rows) and to the watched-ids map
(flaggedWatched/timesWatched, episode _ids already match the id:s:e key
format). Exposed as stremioWatchlistToItems / stremioWatchedToIds for the
desktop shell; Android keeps using the JNI library_state entry points.
- 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)