Desktop's anime-tracking gate, link-based AniList ID extraction, and
search-result best-match selection were duplicated logic Android lacks
entirely (it only tracks progress when a meta id is already prefixed
anilist:). Porting the decision logic here — while keeping GraphQL I/O
and caching on the platform side — lets both platforms share it.
Extends existing cross-platform domain modules (nuvio_sync, addon_store,
home_ranking, watchlist_plan, external_sync, player_policy, player_scrobble,
calendar_plan, search_plan) with logic previously duplicated or missing on
the desktop side, so it's implemented once and reused across platforms.
Kotlin's LibraryItem DTO never declared removed/_mtime, so even though
this file already worked on raw JSON Values, those fields never
survived the trip from the Stremio datastore response. Adds
libraryWatchlistItems (id/name/type/poster/background/updatedAtMs,
excluding removed items) so the pull side can finally see Stremio
library membership as a timestamped list, matching the shape the
mergeWatchlistTimestamped bridge expects. Also fixes
libraryContinueWatchingItems, which never checked "removed" at all --
a removed item with leftover progress state could still surface as
continue-watching.
anilist_entries_to_sync built a "watchlist" array from PLANNING-status
entries but never attached a timestamp to it, so nothing downstream
could run a timestamp merge against it. Adds updatedAtMs (converted
from AniList's updatedAt seconds, falling back to now_ms when absent)
so the Kotlin side can feed these into the same timestamped-merge
path already used for Trakt's watchlist.
AniList integration has been pull-only — no mutation is ever sent, so
watchlist/mark-watched changes made in Fluxa never reach AniList, even
though its OAuth token already has full read/write access (no scope
restriction). Adds a pure function that extracts the numeric media id
from the "anilist:<id>" content id scheme and builds the GraphQL
mutation variables, so the Kotlin side only needs to POST the
variables through the existing generic anilistGraphQl call.
Matches the nuvioImportMergePlan convention (args_json fields carry
raw JSON, not JSON-encoded strings) so Kotlin callers don't need to
double-serialize the local/remote item arrays.
Trakt, Simkl, and Stremio currently reconcile watchlist and watched
state as blind set-unions (merge_external_watchlist_json/merge_external_watched_json)
with no notion of "which side changed more recently." Nuvio already
resolves this kind of conflict with timestamp comparisons; this adds
the same capability as a provider-agnostic core_invoke method
(mergeWatchlistTimestamped/mergeWatchedTimestamped) so Trakt/Simkl/
Stremio can reuse it instead of duplicating Nuvio-specific logic.
Takes local items (id, updatedAt, active) and remote items (id,
updatedAt), and returns which ids to apply locally vs. push to the
remote, picking a winner per id by comparing timestamps.
Retire hand-written per-function JNI bindings in fluxa-core in favor of
the generic core_invoke(method, args_json) dispatcher already exposed
via UniFFI. Every stateless domain function (calendar, watchlist, addon
parsing, trakt/simkl sync, home ranking, player policy, etc.) is now
reachable by method name through core_invoke; jni.rs keeps only the
coreInvoke passthrough and the handle-lifecycle functions that manage
opaque stateful engine/state handles, which don't fit the stateless
JSON-in/JSON-out shape.
Progress rows synced before duration is known carry a bogus >24h
duration; require >=99.5% completion for real durations instead of also
matching <0.5% starts.
An episode-shaped title (tt123:1:2) now resolves to the file whose name
contains s01e02/1x02 before falling back to the largest video, and
fallback candidates are ordered episode-matches-first. Fixes playing the
wrong file from season-pack torrents where a later episode is larger.
fluxa-desktop's nuvioSync.ts held the whole Nuvio import policy in
TypeScript: watchlist mapping, progress-entry construction (up-next
thresholds), watched-history reconciliation, savedAt bumping, profile
and collection mapping. That logic moves here as nuvio_sync.rs so other
shells share one implementation; the desktop keeps only the pulls and
persistence.
Also exposes three policies the desktop had duplicated or hardcoded:
airDateRefreshCandidates (12h cooldown due-check), simklScrobbleAction
(stop/pause threshold, reusing SCROBBLE_STOP_PROGRESS_PERCENT), and
torrentReadyBudget (torrent readiness retry budget).
Derive a playable magnet URI from a stream's infoHash/sources (or
detect an existing magnet/torrent URL) via stream_magnet_link, and
let the offline-download planner accept torrent-sourced streams
instead of rejecting them outright.
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.
Lets the platform feed a scraper's getStreams() output straight into
the existing detailStreamsAppended merge path instead of needing a
parallel stream-ranking system for plugin-sourced streams.
Quality/size/provider/seeders/peers ride along in Stream's extra map
since they don't have first-class fields.
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.
fluxa-desktop was reimplementing several pieces of business logic in
TypeScript instead of delegating to fluxa-core: Trakt/Simkl "similar
titles" response mapping, search/discover source merging, addon HTTP
retry/concurrency/race policy, AnimeSkip episode matching, continue-
watching week partitioning, library merge diffing, and cast/director
link classification. Adds the corresponding pure functions here and
exposes them over ffi so the shell just calls into core and executes.
Phase 0 of the fluxa-android KMP/CMP migration needs an xcframework +
Swift UniFFI bindings for fluxa_core. Adds an "ios" feature (full-api +
uniffi-bindings, no jni/dolby_vision, mirroring the existing "desktop"
feature), a staticlib crate-type for xcframework embedding, a
[bindings.swift] uniffi.toml section, and a macOS-only build script
that produces FluxaCore.xcframework for aarch64-apple-ios and
aarch64-apple-ios-sim.
Verified on Linux: `cargo check --target aarch64-apple-ios --features
ios` and the aarch64-apple-ios-sim equivalent both compile cleanly, so
the crate itself has no Android/JNI-only code leaking into the iOS
feature set. Actual linking/xcframework packaging needs a Mac with
Xcode — untested here.
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.
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.