feat: complete core engine, FFI layer, and streaming engine

- 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)
This commit is contained in:
KhooLy 2026-06-16 01:44:59 +03:00
parent b0c0b93b8e
commit 2f510a6a4b
23 changed files with 1920 additions and 97 deletions

3
Cargo.lock generated
View file

@ -284,7 +284,7 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
[[package]]
name = "dolby_vision"
version = "3.3.2"
source = "git+https://github.com/quietvoid/dovi_tool.git#ccf640939377ee19035bbf871040bf79d437fb6b"
source = "git+https://github.com/quietvoid/dovi_tool.git?rev=ccf640939377ee19035bbf871040bf79d437fb6b#ccf640939377ee19035bbf871040bf79d437fb6b"
dependencies = [
"anyhow",
"bitvec",
@ -333,6 +333,7 @@ dependencies = [
"serde",
"serde_json",
"uniffi",
"wasm-bindgen",
]
[[package]]

View file

@ -15,19 +15,23 @@ required-features = ["uniffi-cli"]
default = ["native"]
native = [
"dep:jni",
"dep:dolby_vision",
"uniffi-bindings",
]
uniffi-cli = ["uniffi/cli"]
wasm = []
uniffi-bindings = ["dep:uniffi"]
uniffi-cli = ["uniffi-bindings", "uniffi/cli"]
wasm = ["dep:wasm-bindgen", "chrono/wasmbind"]
[dependencies]
base64 = "0.22"
chrono = { version = "0.4.45", features = ["serde"] }
dolby_vision = { git = "https://github.com/quietvoid/dovi_tool.git", package = "dolby_vision", default-features = false }
dolby_vision = { git = "https://github.com/quietvoid/dovi_tool.git", rev = "ccf640939377ee19035bbf871040bf79d437fb6b", package = "dolby_vision", default-features = false, optional = true }
jni = { version = "0.21", optional = true }
regex = "1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uniffi = "0.31.1"
uniffi = { version = "0.31.1", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
[profile.release]
opt-level = 3

View file

@ -14,7 +14,6 @@ use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use std::io::SeekFrom;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::Duration;
@ -64,16 +63,24 @@ struct TorrentServerHandle {
}
static TORRENT_SERVER: OnceLock<Mutex<Option<TorrentServerHandle>>> = OnceLock::new();
static TORRENT_SERVER_RUNNING: AtomicBool = AtomicBool::new(false);
fn torrent_server_handle() -> &'static Mutex<Option<TorrentServerHandle>> {
TORRENT_SERVER.get_or_init(|| Mutex::new(None))
}
pub fn start_torrent_server(cache_dir: &str, preferred_port: i32) -> Option<String> {
if TORRENT_SERVER_RUNNING.swap(true, Ordering::SeqCst) {
stop_torrent_server();
TORRENT_SERVER_RUNNING.store(true, Ordering::SeqCst);
// Stop any existing server first. The mutex is the single source of truth
// for whether the server is running — no separate AtomicBool needed.
{
let mut guard = torrent_server_handle().lock().ok()?;
if let Some(mut handle) = guard.take() {
if let Some(stop) = handle.stop.take() {
let _ = stop.send(());
}
if let Some(thread) = handle.thread.take() {
let _ = thread.join();
}
}
}
let cache_dir = PathBuf::from(cache_dir);
@ -98,7 +105,6 @@ pub fn start_torrent_server(cache_dir: &str, preferred_port: i32) -> Option<Stri
Ok(runtime) => runtime,
Err(error) => {
let _ = ready_tx.send(Err(error.to_string()));
TORRENT_SERVER_RUNNING.store(false, Ordering::SeqCst);
return;
}
};
@ -108,7 +114,6 @@ pub fn start_torrent_server(cache_dir: &str, preferred_port: i32) -> Option<Stri
Ok(listener) => listener,
Err(error) => {
let _ = ready_tx.send(Err(error.to_string()));
TORRENT_SERVER_RUNNING.store(false, Ordering::SeqCst);
return;
}
};
@ -136,7 +141,6 @@ pub fn start_torrent_server(cache_dir: &str, preferred_port: i32) -> Option<Stri
Ok(session) => session,
Err(error) => {
let _ = ready_tx.send(Err(format!("{error:#}")));
TORRENT_SERVER_RUNNING.store(false, Ordering::SeqCst);
return;
}
};
@ -159,7 +163,6 @@ pub fn start_torrent_server(cache_dir: &str, preferred_port: i32) -> Option<Stri
});
let _ = ready_tx.send(Ok(()));
let _ = server.await;
TORRENT_SERVER_RUNNING.store(false, Ordering::SeqCst);
});
});
@ -176,10 +179,7 @@ pub fn start_torrent_server(cache_dir: &str, preferred_port: i32) -> Option<Stri
}))
.ok()
}
Err(_) => {
TORRENT_SERVER_RUNNING.store(false, Ordering::SeqCst);
None
}
Err(_) => None,
}
}
@ -187,9 +187,8 @@ pub fn stop_torrent_server() -> bool {
let Some(mut handle) = torrent_server_handle()
.lock()
.ok()
.and_then(|mut handle| handle.take())
.and_then(|mut guard| guard.take())
else {
TORRENT_SERVER_RUNNING.store(false, Ordering::SeqCst);
return false;
};
if let Some(stop) = handle.stop.take() {
@ -198,7 +197,6 @@ pub fn stop_torrent_server() -> bool {
if let Some(thread) = handle.thread.take() {
let _ = thread.join();
}
TORRENT_SERVER_RUNNING.store(false, Ordering::SeqCst);
true
}
@ -223,7 +221,7 @@ async fn torrents(State(state): State<EngineState>, Json(request): Json<TorrRequ
let action = request.action.to_ascii_lowercase();
match action.as_str() {
"add" => {
match ensure_torrent(&state, request.link.as_deref(), request.title.as_deref()).await {
match ensure_torrent(&state, request.link.as_deref(), request.title.as_deref(), request.file_id).await {
Ok((id, details)) => {
let focus = request
.file_id
@ -247,7 +245,7 @@ async fn torrents(State(state): State<EngineState>, Json(request): Json<TorrRequ
{
Some(id) => id,
None => {
match ensure_torrent(&state, request.link.as_deref(), request.title.as_deref())
match ensure_torrent(&state, request.link.as_deref(), request.title.as_deref(), None)
.await
{
Ok((id, _)) => id,
@ -280,7 +278,7 @@ async fn stream_fname(
// Stat requests return immediately — no retry loop (used by Kotlin status polling)
if query.stat.is_some() {
return match ensure_torrent(&state, Some(&query.link), query.title.as_deref()).await {
return match ensure_torrent(&state, Some(&query.link), query.title.as_deref(), None).await {
Ok((id, details)) => status_response(&state, id, Some(details)).await.into_response(),
Err(_) => (StatusCode::SERVICE_UNAVAILABLE, axum::Json(serde_json::json!({
"stat": 0, "preload": 0, "file_stats": [], "download_speed": 0,
@ -293,7 +291,7 @@ async fn stream_fname(
// once is enough — if metadata isn't ready yet, return 503 and let the
// player retry the GET. No outer retry loop (the old 60s loop just hid
// the latency from the user without saving any time).
let (id, details) = match ensure_torrent(&state, Some(&query.link), query.title.as_deref()).await {
let (id, details) = match ensure_torrent(&state, Some(&query.link), query.title.as_deref(), query.index).await {
Ok(value) => value,
Err(error) => {
eprintln!("[TorrServer] ensure_torrent failed: {error}");
@ -305,58 +303,66 @@ async fn stream_fname(
.unwrap_or_else(|| largest_file_id(&details).unwrap_or(0));
eprintln!("[TorrServer] streaming torrent={id} file={file_id} files={}", details.files.as_ref().map(|f| f.len()).unwrap_or(0));
prioritize_stream_file(&state, id, file_id).await;
// Retry until rqbit transitions Initializing→Live. Even if preload is full, api_stream
// fails while state is Initializing. 400 × 50ms = 20s covers the hash-check case
// with finer polling so we start serving bytes the moment rqbit is ready.
let mut last_stream_err = String::new();
for attempt in 0..400u32 {
match state.api.api_stream(TorrentIdOrHash::Id(id), file_id) {
Ok(mut stream) => {
let mut status = StatusCode::OK;
let mut output_headers = HeaderMap::new();
output_headers.insert("Accept-Ranges", HeaderValue::from_static("bytes"));
if let Ok(mime) = state.api.torrent_file_mime_type(TorrentIdOrHash::Id(id), file_id) {
if let Ok(value) = HeaderValue::from_str(mime) {
output_headers.insert("Content-Type", value);
}
}
let total_len = stream.len();
if let Some((start, end)) = parse_range(headers.get("Range"), total_len) {
match stream.seek(SeekFrom::Start(start)).await {
Ok(_) => {
status = StatusCode::PARTIAL_CONTENT;
let end = end.unwrap_or_else(|| total_len.saturating_sub(1));
let length = end.saturating_sub(start).saturating_add(1);
insert_header(&mut output_headers, "Content-Length", length.to_string());
insert_header(&mut output_headers, "Content-Range", format!("bytes {start}-{end}/{total_len}"));
}
Err(error) => {
eprintln!("[TorrServer] seek failed torrent={id} file={file_id} start={start} len={total_len}: {error}");
insert_header(&mut output_headers, "Content-Length", total_len.to_string());
}
}
} else {
insert_header(&mut output_headers, "Content-Length", total_len.to_string());
}
let body = Body::from_stream(ReaderStream::with_capacity(stream, 65536));
return (status, output_headers, body).into_response();
}
Err(e) => {
last_stream_err = format!("{e:#}");
if attempt == 399 {
eprintln!("[TorrServer] api_stream failed after retries torrent={id} file={file_id}: {last_stream_err}");
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
// Wait for rqbit to leave Initializing state before attempting to stream.
// api_stream fails immediately with "invalid state: initializing" until this
// transition happens, so polling was wasting 50ms slots per attempt.
// wait_until_initialized uses a notify channel and fires as soon as it's ready.
if let Ok(handle) = state.api.mgr_handle(TorrentIdOrHash::Id(id)) {
if let Err(e) = tokio::time::timeout(
Duration::from_secs(60),
handle.wait_until_initialized(),
)
.await
{
eprintln!("[TorrServer] wait_until_initialized timed out torrent={id}: {e}");
return error_response(StatusCode::SERVICE_UNAVAILABLE, "torrent init timed out");
}
}
match state.api.api_stream(TorrentIdOrHash::Id(id), file_id) {
Ok(mut stream) => {
let mut status = StatusCode::OK;
let mut output_headers = HeaderMap::new();
output_headers.insert("Accept-Ranges", HeaderValue::from_static("bytes"));
if let Ok(mime) = state.api.torrent_file_mime_type(TorrentIdOrHash::Id(id), file_id) {
if let Ok(value) = HeaderValue::from_str(mime) {
output_headers.insert("Content-Type", value);
}
}
let total_len = stream.len();
if let Some((start, end)) = parse_range(headers.get("Range"), total_len) {
match stream.seek(SeekFrom::Start(start)).await {
Ok(_) => {
status = StatusCode::PARTIAL_CONTENT;
let end = end.unwrap_or_else(|| total_len.saturating_sub(1));
let length = end.saturating_sub(start).saturating_add(1);
insert_header(&mut output_headers, "Content-Length", length.to_string());
insert_header(&mut output_headers, "Content-Range", format!("bytes {start}-{end}/{total_len}"));
}
Err(error) => {
eprintln!("[TorrServer] seek failed torrent={id} file={file_id} start={start} len={total_len}: {error}");
insert_header(&mut output_headers, "Content-Length", total_len.to_string());
}
}
} else {
insert_header(&mut output_headers, "Content-Length", total_len.to_string());
}
let body = Body::from_stream(ReaderStream::with_capacity(stream, 65536));
(status, output_headers, body).into_response()
}
Err(e) => {
eprintln!("[TorrServer] api_stream failed torrent={id} file={file_id}: {e:#}");
error_response(StatusCode::NOT_FOUND, format!("{e:#}"))
}
}
error_response(StatusCode::NOT_FOUND, last_stream_err)
}
async fn ensure_torrent(
state: &EngineState,
link: Option<&str>,
title: Option<&str>,
only_file: Option<usize>,
) -> Result<(usize, TorrentDetailsResponse), String> {
let link = link
.map(str::trim)
@ -377,6 +383,11 @@ async fn ensure_torrent(
read_write_timeout: Some(Duration::from_secs(20)),
..Default::default()
});
// Limit rqbit initialization to just the target file so the Initializing
// hash-check covers one file instead of every file in the torrent.
if let Some(file_id) = only_file {
options.only_files = Some(vec![file_id]);
}
let response = state
.api
.api_add_torrent(AddTorrent::Url(link.to_string().into()), Some(options))

View file

@ -1,3 +1,6 @@
#[cfg(feature = "native")]
pub mod jni;
#[cfg(feature = "uniffi-bindings")]
pub mod uniffi;
#[cfg(feature = "wasm")]
pub mod wasm;

View file

@ -5,6 +5,12 @@ pub fn fluxa_core_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
/// Funnel entry point — Swift calls this instead of binding each helper.
#[uniffi::export]
pub fn core_invoke(method: String, args_json: String) -> String {
crate::ffi::core_invoke(&method, &args_json)
}
#[uniffi::export]
pub fn create_headless_engine_json(initial_json: String) -> i64 {
headless_engine::create_headless_engine(&initial_json) as i64

11
src/bindings/wasm.rs Normal file
View file

@ -0,0 +1,11 @@
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn core_invoke(method: &str, args_json: &str) -> String {
crate::ffi::core_invoke(method, args_json)
}
#[wasm_bindgen]
pub fn fluxa_core_version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}

View file

@ -266,7 +266,7 @@ pub(crate) fn calendar_items_from_meta_json(meta_json: &str, month_prefix: &str)
let mut items: Vec<Value> = Vec::new();
for video in videos {
let released = video.get("released").and_then(Value::as_str).unwrap_or("");
let date_iso = if released.len() >= 10 { &released[..10] } else { continue };
let date_iso = match released.get(..10) { Some(d) => d, None => continue };
if !month_prefix.is_empty() && !date_iso.starts_with(month_prefix) { continue; }
let season = video.get("season").and_then(Value::as_i64);
let episode = video.get("episode").or_else(|| video.get("number")).and_then(Value::as_i64);

View file

@ -27,10 +27,6 @@ use crate::tmdb_plan;
use crate::watchlist_plan;
use serde_json::json;
/// Platform-neutral Fluxa runtime API.
///
/// This surface deliberately avoids Android/JNI types. Platform shells can call
/// it directly from Rust, or build their own thin FFI/UniFFI/WASM adapter on top.
pub struct FluxaCore;
impl FluxaCore {
@ -925,7 +921,7 @@ impl FluxaCore {
offline_download::offline_download_plan_json(request_json)
}
// ── content_identity extras ───────────────────────────────────────────────
// content_identity extras
pub fn parse_video_id_json(id: &str) -> String {
content_identity::parse_video_id_json(id)
@ -935,7 +931,7 @@ impl FluxaCore {
content_identity::build_trakt_ids_json(video_id)
}
// ── calendar extras ───────────────────────────────────────────────────────
// calendar extras
pub fn calendar_items_from_meta_json(meta_json: &str, month_prefix: &str) -> Option<String> {
calendar_plan::calendar_items_from_meta_json(meta_json, month_prefix)
@ -945,7 +941,7 @@ impl FluxaCore {
calendar_plan::calendar_item_matches_month_json(item_json, month_prefix)
}
// ── external_sync: Trakt high-level ──────────────────────────────────────
// external_sync: Trakt high-level
pub fn trakt_playback_items_to_library_json(items_json: &str) -> Option<String> {
external_sync::trakt_playback_items_to_library_json(items_json)
@ -975,7 +971,7 @@ impl FluxaCore {
external_sync::merge_continue_watching_lists_json(local_json, external_json, progress_json)
}
// ── external_sync: Simkl ─────────────────────────────────────────────────
// external_sync: Simkl
pub fn simkl_watching_to_items_json(shows_json: &str, movies_json: &str) -> Option<String> {
external_sync::simkl_watching_to_items_json(shows_json, movies_json)
@ -989,7 +985,30 @@ impl FluxaCore {
external_sync::simkl_watched_to_ids_json(shows_json, movies_json)
}
// ── library_state extras ─────────────────────────────────────────────────
pub fn trakt_scrobble_plan_json(
video_id: &str,
is_episode: bool,
season: Option<i64>,
ep_number: Option<i64>,
time_pos_sec: f64,
duration_sec: f64,
) -> Option<String> {
let ids_json = content_identity::build_trakt_ids_json(video_id)?;
player_scrobble::trakt_scrobble_plan_json(&ids_json, is_episode, season, ep_number, time_pos_sec, duration_sec)
}
pub fn simkl_scrobble_body_json(
ids_json: &str,
is_episode: bool,
season: i64,
ep_number: i64,
time_pos_sec: f64,
duration_sec: f64,
) -> Option<String> {
player_scrobble::simkl_scrobble_body_json(ids_json, is_episode, season, ep_number, time_pos_sec, duration_sec)
}
// library_state extras
pub fn normalize_library_document_json(json: &str) -> String {
library_state::normalize_library_document_json(json)
@ -1021,7 +1040,7 @@ impl FluxaCore {
)
}
// ── tmdb_plan ────────────────────────────────────────────────────────────
// tmdb_plan
pub fn tmdb_content_type(content_type: &str) -> &str {
tmdb_plan::tmdb_content_type(content_type)
@ -1059,7 +1078,7 @@ impl FluxaCore {
tmdb_plan::tmdb_resolve_id_hint(content_id)
}
// ── intro_segments ────────────────────────────────────────────────────────
// intro_segments
pub fn parse_intro_db_segments_json(data_json: &str) -> Option<String> {
intro_segments::parse_intro_db_segments_json(data_json)
@ -1076,6 +1095,100 @@ impl FluxaCore {
pub fn merge_intro_segments_json(sources_json: &str) -> Option<String> {
intro_segments::merge_intro_segments_json(sources_json)
}
// library_state: episode navigation
pub fn resolve_next_episode_json(
videos_json: &str,
current_season: i64,
current_episode: i64,
now_ms: i64,
released_only: bool,
) -> Option<String> {
library_state::resolve_next_episode_json(
videos_json,
current_season,
current_episode,
now_ms,
released_only,
)
}
pub fn format_episode_line_json(
last_episode_name: Option<&str>,
last_episode_season: Option<i64>,
last_episode_number: Option<i64>,
last_video_id: Option<&str>,
) -> String {
library_state::format_episode_line_json(
last_episode_name,
last_episode_season,
last_episode_number,
last_video_id,
)
}
pub fn select_continue_watching_artwork_json(
item_json: &str,
artwork_preference: &str,
is_horizontal: bool,
) -> Option<String> {
library_state::select_continue_watching_artwork_json(item_json, artwork_preference, is_horizontal)
}
// external_sync: continue watching
pub fn replace_external_continue_watching_json(
existing_json: &str,
provider: Option<&str>,
items_json: &str,
) -> String {
external_sync::replace_external_continue_watching_json(existing_json, provider, items_json)
}
// platform_plan: resource protocol helpers
pub fn resource_kind_to_resource_json(kind: &str, request_resource: Option<&str>, item_resource: Option<&str>) -> String {
platform_plan::resource_kind_to_resource(kind, request_resource, item_resource)
}
pub fn wrap_addon_resource_response_json(resource: &str, payload_json: &str) -> String {
platform_plan::wrap_addon_resource_response(resource, payload_json)
}
// player_policy: next episode prefetch
pub fn can_prefetch_next_episode_json(prefs_json: &str, stream_json: &str) -> bool {
player_policy::can_prefetch_next_episode_json(prefs_json, stream_json)
}
pub fn select_next_episode_stream_json(
streams_json: &str,
current_stream_json: &str,
prefs_json: &str,
) -> Option<String> {
player_policy::select_next_episode_stream_json(streams_json, current_stream_json, prefs_json)
}
// watchlist_plan: collections import/export
pub fn import_collections_json(raw_json: &str) -> Option<String> {
watchlist_plan::import_collections_json(raw_json)
}
pub fn export_collections_json(collections_json: &str) -> Option<String> {
watchlist_plan::export_collections_json(collections_json)
}
// search_plan: catalog/transport resolution
pub fn resolve_transport_url_json(source_json: &str, addons_json: &str) -> Option<String> {
search_plan::resolve_transport_url_json(source_json, addons_json)
}
pub fn resolve_feed_option_genre_json(feed_option_json: &str, addons_json: &str) -> Option<String> {
search_plan::resolve_feed_option_genre_json(feed_option_json, addons_json)
}
}
#[cfg(test)]

View file

@ -1,4 +1,4 @@
use crate::content_identity::{base_content_id, parse_episode_locator};
use crate::content_identity::{base_content_id, parse_episode_locator, parse_video_id_json};
use serde_json::{json, Map, Value};
const TRAKT_API_BASE_URL: &str = "https://api.trakt.tv";
@ -521,6 +521,189 @@ pub(crate) fn simkl_watched_to_ids_json(shows_json: &str, movies_json: &str) ->
serde_json::to_string(&Value::Object(ids)).ok()
}
/// Replaces or merges external continue-watching items from one provider.
/// Items from other providers are kept; items from `provider` are replaced.
/// Deduplicates by `id`, keeping the entry with the most recent `savedAt`.
pub(crate) fn replace_external_continue_watching_json(
existing_json: &str,
provider: Option<&str>,
items_json: &str,
) -> String {
let existing: Vec<Value> = serde_json::from_str(existing_json).unwrap_or_default();
let incoming: Vec<Value> = serde_json::from_str(items_json).unwrap_or_default();
let incoming_filtered: Vec<Value> = incoming
.into_iter()
.filter(|item| {
let id = item.get("id").and_then(Value::as_str).unwrap_or("").trim();
let offset = item.get("timeOffset").and_then(Value::as_f64).unwrap_or(0.0);
let duration = item.get("duration").and_then(Value::as_f64).unwrap_or(0.0);
!id.is_empty() && offset > 0.0 && duration > 0.0
})
.collect();
let base: Vec<Value> = if let Some(prov) = provider {
existing
.into_iter()
.filter(|item| item.get("reason").and_then(Value::as_str) != Some(prov))
.collect()
} else {
Vec::new()
};
let combined = base.into_iter().chain(incoming_filtered);
let mut by_id: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
for item in combined {
let id = item.get("id").and_then(Value::as_str).unwrap_or("").to_string();
if id.is_empty() { continue; }
let item_time = item.get("savedAt").and_then(Value::as_str).unwrap_or("").to_string();
match by_id.get(&id) {
Some(prev) => {
let prev_time = prev.get("savedAt").and_then(Value::as_str).unwrap_or("");
if item_time.as_str() > prev_time { by_id.insert(id, item); }
}
None => { by_id.insert(id, item); }
}
}
let result: Vec<Value> = by_id.into_values().collect();
serde_json::to_string(&result).unwrap_or_else(|_| "[]".to_string())
}
pub(crate) fn trakt_playback_items_dedup_json(items_json: &str) -> Option<String> {
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
fn saved_at_str<'a>(item: &'a Value) -> &'a str {
item.get("savedAt").and_then(Value::as_str).unwrap_or("")
}
let mut best: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
for item in items {
let id = item.get("id").and_then(Value::as_str).unwrap_or("").to_string();
if id.is_empty() {
continue;
}
let cur = saved_at_str(&item).to_string();
match best.get(&id) {
None => { best.insert(id, item); }
Some(existing) if cur.as_str() > saved_at_str(existing) => { best.insert(id, item); }
_ => {}
}
}
let mut deduped: Vec<Value> = best.into_values().collect();
deduped.sort_by(|a, b| saved_at_str(b).cmp(saved_at_str(a)));
serde_json::to_string(&deduped).ok()
}
pub(crate) fn trakt_mark_watched_body_json(video_ids_json: &str) -> Option<String> {
let video_ids: Vec<String> = serde_json::from_str(video_ids_json).ok()?;
let mut movie_ids: Vec<Value> = Vec::new();
let mut shows: std::collections::HashMap<String, (Value, std::collections::BTreeMap<i64, Vec<i64>>)> =
std::collections::HashMap::new();
for vid in &video_ids {
let parsed_json = parse_video_id_json(vid);
let parsed: Value = match serde_json::from_str(&parsed_json) {
Ok(v) => v,
Err(_) => continue,
};
let ids_json = match trakt_ids_from_content_id_json(vid) {
Some(j) => j,
None => continue,
};
let ids: Value = match serde_json::from_str(&ids_json) {
Ok(v) => v,
Err(_) => continue,
};
if parsed.get("isEpisode").and_then(Value::as_bool).unwrap_or(false) {
let season = parsed.get("season").and_then(Value::as_i64).unwrap_or(1);
let episode = parsed.get("episode").and_then(Value::as_i64).unwrap_or(1);
let show_id = parsed
.get("imdb")
.or_else(|| parsed.get("tmdb"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if show_id.is_empty() {
continue;
}
let entry = shows.entry(show_id).or_insert_with(|| (ids, std::collections::BTreeMap::new()));
entry.1.entry(season).or_default().push(episode);
} else {
movie_ids.push(json!({ "ids": ids }));
}
}
let show_entries: Vec<Value> = shows
.into_values()
.map(|(ids, seasons)| {
let seasons_arr: Vec<Value> = seasons
.into_iter()
.map(|(season, mut episodes)| {
episodes.sort_unstable();
episodes.dedup();
json!({
"number": season,
"episodes": episodes.into_iter().map(|n| json!({ "number": n })).collect::<Vec<_>>()
})
})
.collect();
json!({ "ids": ids, "seasons": seasons_arr })
})
.collect();
let mut body = serde_json::Map::new();
if !movie_ids.is_empty() {
body.insert("movies".into(), movie_ids.into());
}
if !show_entries.is_empty() {
body.insert("shows".into(), show_entries.into());
}
if body.is_empty() {
return None;
}
serde_json::to_string(&Value::Object(body)).ok()
}
pub(crate) fn simkl_match_episode_json(episodes_json: &str, target_json: &str) -> Option<String> {
let episodes: Vec<Value> = serde_json::from_str(episodes_json).ok()?;
let target: Value = serde_json::from_str(target_json).ok()?;
let release_date = target.get("releaseDate").and_then(Value::as_str).unwrap_or("");
let title = target
.get("title")
.and_then(Value::as_str)
.unwrap_or("")
.to_lowercase();
let title = title.trim();
let matched = if !release_date.is_empty() {
episodes.iter().find(|ep| {
ep.get("date")
.and_then(Value::as_str)
.is_some_and(|d| d.starts_with(release_date))
})
} else {
None
};
let matched = matched.or_else(|| {
if title.is_empty() {
return None;
}
episodes.iter().find(|ep| {
ep.get("title")
.and_then(Value::as_str)
.is_some_and(|t| t.to_lowercase().trim() == title)
})
})?;
let season = matched.get("season").and_then(Value::as_i64)?;
let episode = matched.get("episode").and_then(Value::as_i64)?;
serde_json::to_string(&json!({ "season": season, "episode": episode })).ok()
}
#[cfg(test)]
mod tests {
use super::*;

683
src/ffi.rs Normal file
View file

@ -0,0 +1,683 @@
use serde_json::{json, Value};
use crate::{
addon_protocol, addon_resource, app_state, calendar_plan, content_identity, core_contract,
external_sync, headless_engine, home_ranking, intro_segments, library_state, offline_download,
platform_plan, player_policy, player_scrobble, repository_flow, search_plan, stream_policy,
tmdb_plan, watchlist_plan,
};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum ErrorKind {
UnknownMethod,
InvalidArgs,
NotFound,
Internal,
}
impl ErrorKind {
fn as_str(self) -> &'static str {
match self {
ErrorKind::UnknownMethod => "unknown_method",
ErrorKind::InvalidArgs => "invalid_args",
ErrorKind::NotFound => "not_found",
ErrorKind::Internal => "internal",
}
}
}
struct CallError {
kind: ErrorKind,
message: String,
}
fn fail(kind: ErrorKind, message: impl Into<String>) -> CallError {
CallError { kind, message: message.into() }
}
type Outcome = Result<Value, CallError>;
pub fn core_invoke(method: &str, args_json: &str) -> String {
match route(method, args_json) {
Ok(value) => json!({ "ok": true, "value": value }).to_string(),
Err(e) => json!({
"ok": false,
"error": { "kind": e.kind.as_str(), "message": e.message, "method": method },
})
.to_string(),
}
}
fn route(method: &str, args_json: &str) -> Outcome {
match method {
// Engine lifecycle
"engine.create" => Ok(json!(headless_engine::create_headless_engine(args_json) as i64)),
"engine.snapshot" => result_json(
headless_engine::headless_engine_snapshot_json(handle(args_json)?),
method,
),
"engine.dispatch" => {
let args = object(args_json)?;
result_json(
headless_engine::headless_engine_dispatch_json(
field_u64(&args, "handle")?,
&field(&args, "action")?.to_string(),
),
method,
)
}
"engine.completeEffect" => {
let args = object(args_json)?;
result_json(
headless_engine::headless_engine_complete_effect_json(
field_u64(&args, "handle")?,
&field(&args, "result")?.to_string(),
),
method,
)
}
"engine.destroy" => Ok(json!(headless_engine::destroy_headless_engine(handle(args_json)?))),
// App state (parallel to headless engine, used by Android)
"app.create" => Ok(json!(app_state::create_app_core_state(args_json) as i64)),
"app.state" => result_json(app_state::app_core_state_json(handle(args_json)?), method),
"app.dispatch" => {
let args = object(args_json)?;
result_json(
app_state::app_core_dispatch_json(
field_u64(&args, "handle")?,
&field(&args, "action")?.to_string(),
),
method,
)
}
"app.destroy" => Ok(json!(app_state::destroy_app_core_state(handle(args_json)?))),
// Addon protocol — manifest
"identity" => Ok(Value::String(addon_protocol::identity(&arg_str(args_json, "url")?))),
"normalizeManifestUrl" => Ok(Value::String(addon_protocol::normalize_manifest_url(&arg_str(args_json, "url")?))),
"manifestFetchPlan" => opt_json(addon_protocol::manifest_fetch_plan_json(&arg_str(args_json, "url")?)),
"parseManifest" => {
let args = object(args_json)?;
opt_json(addon_protocol::parse_manifest(
field_str(&args, "body")?,
field_str(&args, "transportUrl")?,
"Unknown Addon",
))
}
// args_json IS the descriptor object
"resolveManifestAssets" => opt_json(addon_protocol::resolve_manifest_assets_json(args_json)),
"mergeLiveManifest" => {
let args = object(args_json)?;
let live = args.get("live").and_then(Value::as_str).map(str::to_string);
let name = args.get("unknownName").and_then(Value::as_str).unwrap_or("Unknown Addon");
opt_json(addon_protocol::merge_live_manifest_json(
field_str(&args, "descriptor")?,
live.as_deref(),
name,
))
}
"buildResourceUrl" => {
let args = object(args_json)?;
let extra = args.get("extraJson").and_then(Value::as_str).map(str::to_string);
Ok(Value::String(addon_protocol::build_resource_url(
field_str(&args, "transportUrl")?,
field_str(&args, "resource")?,
field_str(&args, "contentType")?,
field_str(&args, "id")?,
extra.as_deref(),
)))
}
"supportsResource" => {
let args = object(args_json)?;
let content_type = args.get("contentType").and_then(Value::as_str).map(str::to_string);
let id = args.get("id").and_then(Value::as_str).map(str::to_string);
Ok(json!(addon_protocol::supports_resource(
field_str(&args, "manifest")?,
field_str(&args, "resource")?,
content_type.as_deref(),
id.as_deref(),
)))
}
"catalogSupportsExtra" => {
let args = object(args_json)?;
Ok(json!(addon_protocol::catalog_supports_extra(
field_str(&args, "catalog")?,
field_str(&args, "extraName")?,
)))
}
"catalogRequiresExtra" => {
let args = object(args_json)?;
Ok(json!(addon_protocol::catalog_requires_extra(
field_str(&args, "catalog")?,
field_str(&args, "extraName")?,
)))
}
"catalogHasRequiredExtraExcept" => {
let args = object(args_json)?;
Ok(json!(addon_protocol::catalog_has_required_extra_except(
field_str(&args, "catalog")?,
field_str(&args, "allowedNames")?,
)))
}
// Addon resource
"parseAddonResourceResult" => {
let args = object(args_json)?;
let body = args.get("body").and_then(Value::as_str).map(str::to_string);
let status_code = field(&args, "statusCode")?.as_i64()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "statusCode must be a number"))? as i32;
into_json(addon_resource::parse_addon_resource_result_json(
field_str(&args, "resource")?,
field_str(&args, "url")?,
status_code,
body.as_deref(),
))
}
"normalizeAddonSubtitles" => {
let args = object(args_json)?;
into_json(addon_resource::normalize_addon_subtitles_json(
field_str(&args, "subtitles")?,
field_str(&args, "resourceUrl")?,
))
}
// Repository / resource flow — args_json IS the request object
"addonResourceRequestPlan" => opt_json(repository_flow::addon_resource_request_plan_json(args_json)),
"resourceFetchPlan" => opt_json(platform_plan::resource_fetch_plan_json(args_json)),
"resourceParsePlan" => opt_json(platform_plan::resource_parse_plan_json(args_json)),
// Platform plan — args_json IS the request object
"playbackPreparePlan" => opt_json(platform_plan::playback_prepare_plan_json(args_json)),
"libraryLocalStatePlan" => opt_json(platform_plan::library_local_state_plan_json(args_json)),
"preferencesSchema" => into_json(platform_plan::preferences_schema_json()),
"applyPreferenceUpdate" => opt_json(platform_plan::apply_preference_update_json(args_json)),
"addonCollectionMutationPlan" => opt_json(platform_plan::addon_collection_mutation_plan_json(args_json)),
"detailEpisodePlan" => opt_json(platform_plan::detail_episode_plan_json(args_json)),
"resourceKindToResource" => {
let args = object(args_json)?;
Ok(Value::String(platform_plan::resource_kind_to_resource(
field_str(&args, "kind")?,
args.get("requestResource").and_then(Value::as_str),
args.get("itemResource").and_then(Value::as_str),
)))
}
"wrapAddonResourceResponse" => {
let args = object(args_json)?;
into_json(platform_plan::wrap_addon_resource_response(
field_str(&args, "resource")?,
field_str(&args, "payloadJson")?,
))
}
// Stream policy — args_json IS the stream/request JSON
"streamPlaybackInfo" => opt_json(stream_policy::stream_playback_info_json(args_json)),
"torrentRuntimeInfo" => opt_json(stream_policy::torrent_runtime_info_json(args_json)),
"findPreferredSubtitleIndex" => {
let args = object(args_json)?;
let last = args.get("lastSubtitleLanguage").and_then(Value::as_str).map(str::to_string);
let preferred = args.get("preferredSubtitleLanguage").and_then(Value::as_str).map(str::to_string);
let secondary = args.get("secondarySubtitleLanguage").and_then(Value::as_str).map(str::to_string);
Ok(json!(stream_policy::find_preferred_subtitle_index(
field_str(&args, "tracks")?,
last.as_deref(),
preferred.as_deref(),
secondary.as_deref(),
)))
}
// Search / discovery — args_json IS the request object for single-arg methods
"searchResultGrouping" => opt_json(search_plan::search_result_grouping_json(args_json)),
"buildMetadataFeedOptions" => opt_json(search_plan::build_metadata_feed_options_json(args_json)),
"discoverCatalogOptions" => {
let args = object(args_json)?;
opt_json(search_plan::discover_catalog_options_json(
field_str(&args, "addons")?,
field_str(&args, "selectedType")?,
))
}
"discoverSortPlan" => opt_json(search_plan::discover_sort_plan_json(args_json)),
"librarySortPlan" => opt_json(search_plan::library_sort_plan_json(args_json)),
"detailSeriesLookupId" => Ok(Value::String(search_plan::detail_series_lookup_id(&arg_str(args_json, "id")?))),
"detailSeasonLoadPlan" => opt_json(search_plan::detail_season_load_plan_json(args_json)),
"resolveTransportUrl" => {
let args = object(args_json)?;
opt_json(search_plan::resolve_transport_url_json(
field_str(&args, "sourceJson")?,
field_str(&args, "addonsJson")?,
))
}
"resolveFeedOptionGenre" => {
let args = object(args_json)?;
opt_json(search_plan::resolve_feed_option_genre_json(
field_str(&args, "feedOptionJson")?,
field_str(&args, "addonsJson")?,
))
}
// Player policy — args_json IS the request object for single-arg methods
"playerBackendSelection" => opt_json(player_policy::player_backend_selection_json(args_json)),
"playerBufferTargets" => opt_json(player_policy::player_buffer_targets_json(args_json)),
"playerRetryPolicy" => opt_json(player_policy::player_retry_policy_json(args_json)),
"playerSourceSidebarPlan" => opt_json(player_policy::player_source_sidebar_plan_json(args_json)),
"canPrefetchNextEpisode" => {
let args = object(args_json)?;
Ok(json!(player_policy::can_prefetch_next_episode_json(
field_str(&args, "prefsJson")?,
field_str(&args, "streamJson")?,
)))
}
"selectNextEpisodeStream" => {
let args = object(args_json)?;
opt_json(player_policy::select_next_episode_stream_json(
field_str(&args, "streamsJson")?,
field_str(&args, "currentStreamJson")?,
field_str(&args, "prefsJson")?,
))
}
// Watchlist / library — args_json IS the request object
"watchlistTogglePlan" => opt_json(watchlist_plan::watchlist_toggle_plan_json(args_json)),
"playbackProgressMergePlan" => opt_json(watchlist_plan::playback_progress_merge_plan_json(args_json)),
"libraryApplyMarkWatched" => {
let args = object(args_json)?;
opt_json(watchlist_plan::library_apply_mark_watched_json(
field_str(&args, "libJson")?,
field_str(&args, "videoIdsJson")?,
))
}
"mergeProgressMeta" => {
let args = object(args_json)?;
into_json(watchlist_plan::merge_progress_meta_json(
field_str(&args, "incomingMetaJson")?,
field_str(&args, "existingMetaJson")?,
))
}
"importCollections" => opt_json(watchlist_plan::import_collections_json(args_json)),
"exportCollections" => opt_json(watchlist_plan::export_collections_json(args_json)),
// Offline — args_json IS the request object
"offlineDownloadPlan" => opt_json(offline_download::offline_download_plan_json(args_json)),
// Content identity
"parseVideoId" => into_json(content_identity::parse_video_id_json(&arg_str(args_json, "id")?)),
"buildTraktIds" => opt_json(content_identity::build_trakt_ids_json(&arg_str(args_json, "id")?)),
"playbackIntroLookupContentId" => Ok(Value::String(content_identity::playback_intro_lookup_content_id(&arg_str(args_json, "id")?))),
"effectiveMetadataFeedSelection" => {
let args = object(args_json)?;
opt_json(content_identity::effective_metadata_feed_selection_json(
field_str(&args, "selectedKeys")?,
field_str(&args, "availableKeys")?,
))
}
"toggleMetadataFeedLimited" => {
let args = object(args_json)?;
let max_enabled = field(&args, "maxEnabled")?.as_i64()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "maxEnabled must be a number"))? as i32;
opt_json(content_identity::toggle_metadata_feed_limited_json(
field_str(&args, "selectedKeys")?,
field_str(&args, "availableKeys")?,
field_str(&args, "key")?,
max_enabled,
))
}
// Calendar
"calendarItemsFromMeta" => {
let args = object(args_json)?;
opt_json(calendar_plan::calendar_items_from_meta_json(
field_str(&args, "metaJson")?,
field_str(&args, "monthPrefix")?,
))
}
"calendarItemMatchesMonth" => {
let args = object(args_json)?;
Ok(json!(calendar_plan::calendar_item_matches_month_json(
field_str(&args, "itemJson")?,
field_str(&args, "monthPrefix")?,
)))
}
// External sync: Trakt
// args_json IS the items array for single-array-arg methods
"traktPlaybackItemsToLibrary" => opt_json(external_sync::trakt_playback_items_to_library_json(args_json)),
"traktWatchlistToItems" => {
let args = object(args_json)?;
opt_json(external_sync::trakt_watchlist_to_items_json(
field_str(&args, "moviesJson")?,
field_str(&args, "showsJson")?,
))
}
"traktWatchedToIds" => {
let args = object(args_json)?;
opt_json(external_sync::trakt_watched_to_ids_json(
field_str(&args, "moviesJson")?,
field_str(&args, "showsJson")?,
))
}
"mergeExternalWatchlist" => {
let args = object(args_json)?;
into_json(external_sync::merge_external_watchlist_json(
field_str(&args, "localJson")?,
field_str(&args, "externalJson")?,
))
}
"mergeExternalWatched" => {
let args = object(args_json)?;
into_json(external_sync::merge_external_watched_json(
field_str(&args, "localJson")?,
field_str(&args, "externalJson")?,
))
}
"mergeContinueWatchingLists" => {
let args = object(args_json)?;
opt_json(external_sync::merge_continue_watching_lists_json(
field_str(&args, "localJson")?,
field_str(&args, "externalJson")?,
field_str(&args, "progressJson")?,
))
}
"traktScrobblePlan" => {
let args = object(args_json)?;
let season = args.get("season").and_then(Value::as_i64);
let ep_number = args.get("epNumber").and_then(Value::as_i64);
let time_pos = field(&args, "timePosSec")?.as_f64()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "timePosSec must be a number"))?;
let duration = field(&args, "durationSec")?.as_f64()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "durationSec must be a number"))?;
let ids_json = content_identity::build_trakt_ids_json(field_str(&args, "videoId")?)
.ok_or_else(|| fail(ErrorKind::NotFound, "could not build trakt ids"))?;
opt_json(player_scrobble::trakt_scrobble_plan_json(
&ids_json,
field(&args, "isEpisode")?.as_bool()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "isEpisode must be bool"))?,
season,
ep_number,
time_pos,
duration,
))
}
"replaceExternalContinueWatching" => {
let args = object(args_json)?;
let provider = args.get("provider").and_then(Value::as_str);
into_json(external_sync::replace_external_continue_watching_json(
field_str(&args, "existingJson")?,
provider,
field_str(&args, "itemsJson")?,
))
}
// External sync: Simkl
"simklWatchingToItems" => {
let args = object(args_json)?;
opt_json(external_sync::simkl_watching_to_items_json(
field_str(&args, "showsJson")?,
field_str(&args, "moviesJson")?,
))
}
"simklWatchlistToItems" => {
let args = object(args_json)?;
opt_json(external_sync::simkl_watchlist_to_items_json(
field_str(&args, "showsJson")?,
field_str(&args, "moviesJson")?,
))
}
"simklWatchedToIds" => {
let args = object(args_json)?;
opt_json(external_sync::simkl_watched_to_ids_json(
field_str(&args, "showsJson")?,
field_str(&args, "moviesJson")?,
))
}
"simklScrobbleBody" => {
let args = object(args_json)?;
let season = field(&args, "season")?.as_i64()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "season must be a number"))?;
let ep_number = field(&args, "epNumber")?.as_i64()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "epNumber must be a number"))?;
let time_pos = field(&args, "timePosSec")?.as_f64()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "timePosSec must be a number"))?;
let duration = field(&args, "durationSec")?.as_f64()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "durationSec must be a number"))?;
opt_json(player_scrobble::simkl_scrobble_body_json(
field_str(&args, "idsJson")?,
field(&args, "isEpisode")?.as_bool()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "isEpisode must be bool"))?,
season,
ep_number,
time_pos,
duration,
))
}
"traktPlaybackItemsDedup" => opt_json(external_sync::trakt_playback_items_dedup_json(args_json)),
"traktMarkWatchedBody" => opt_json(external_sync::trakt_mark_watched_body_json(args_json)),
"simklMatchEpisode" => {
let args = object(args_json)?;
opt_json(external_sync::simkl_match_episode_json(
field_str(&args, "episodesJson")?,
field_str(&args, "targetJson")?,
))
}
// Library state — args_json IS the items/item/doc JSON for single-arg methods
"libraryContinueWatchingItems" => opt_json(library_state::library_continue_watching_items_json(args_json)),
"normalizeLibraryDocument" => into_json(library_state::normalize_library_document_json(args_json)),
"isUpNextContinueWatchingItem" => Ok(json!(library_state::is_up_next_continue_watching_item_json(args_json))),
"buildContinueWatchingFromProgress" => opt_json(library_state::build_continue_watching_from_progress_json(args_json)),
"rememberLastWatchedEpisodes" => {
let args = object(args_json)?;
into_json(library_state::remember_last_watched_episodes_json(
field_str(&args, "libJson")?,
field_str(&args, "watchedIdsJson")?,
))
}
"computeContinueWatchingBadges" => {
let args = object(args_json)?;
let now_ms = field(&args, "nowMs")?.as_i64()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "nowMs must be a number"))?;
opt_json(library_state::compute_continue_watching_badges_json(
field_str(&args, "candidatesJson")?,
field_str(&args, "videosBySeriesJson")?,
field_str(&args, "lastWatchedJson")?,
now_ms,
))
}
"resolveNextEpisode" => {
let args = object(args_json)?;
opt_json(library_state::resolve_next_episode_json(
&field(&args, "videos")?.to_string(),
field(&args, "currentSeason")?.as_i64().ok_or_else(|| fail(ErrorKind::InvalidArgs, "currentSeason must be a number"))?,
field(&args, "currentEpisode")?.as_i64().ok_or_else(|| fail(ErrorKind::InvalidArgs, "currentEpisode must be a number"))?,
field(&args, "nowMs")?.as_i64().ok_or_else(|| fail(ErrorKind::InvalidArgs, "nowMs must be a number"))?,
field(&args, "releasedOnly")?.as_bool().ok_or_else(|| fail(ErrorKind::InvalidArgs, "releasedOnly must be bool"))?,
))
}
"formatEpisodeLine" => {
let args = object(args_json)?;
Ok(Value::String(library_state::format_episode_line_json(
args.get("lastEpisodeName").and_then(Value::as_str),
args.get("lastEpisodeSeason").and_then(Value::as_i64),
args.get("lastEpisodeNumber").and_then(Value::as_i64),
args.get("lastVideoId").and_then(Value::as_str),
)))
}
"selectContinueWatchingArtwork" => {
let args = object(args_json)?;
Ok(json!(library_state::select_continue_watching_artwork_json(
&field(&args, "item")?.to_string(),
field_str(&args, "artworkPreference")?,
field(&args, "isHorizontal")?.as_bool().ok_or_else(|| fail(ErrorKind::InvalidArgs, "isHorizontal must be bool"))?,
)))
}
"buildHomeCollectionShelves" => {
let args = object(args_json)?;
opt_json(home_ranking::build_home_collection_shelves_json(
field_str(&args, "profileJson")?,
field_str(&args, "addonsJson")?,
))
}
// TMDB
"tmdbContentType" => Ok(Value::String(tmdb_plan::tmdb_content_type(&arg_str(args_json, "contentType")?).to_string())),
"tmdbLanguage" => Ok(Value::String(tmdb_plan::tmdb_language(&arg_str(args_json, "language")?))),
"tmdbImageUrl" => {
let args = object(args_json)?;
Ok(json!(tmdb_plan::tmdb_image_url(
args.get("path").and_then(Value::as_str),
field_str(&args, "size")?,
)))
}
"tmdbMetaToMeta" => {
let args = object(args_json)?;
opt_json(tmdb_plan::tmdb_meta_to_meta_json(
field_str(&args, "itemJson")?,
field_str(&args, "requestedType")?,
field_str(&args, "language")?,
))
}
// args_json IS the video/items JSON for single-arg methods
"tmdbVideoToTrailer" => opt_json(tmdb_plan::tmdb_video_to_trailer_json(args_json)),
"tmdbBulkMetas" => {
let args = object(args_json)?;
opt_json(tmdb_plan::tmdb_bulk_metas_to_metas_json(
field_str(&args, "itemsJson")?,
field_str(&args, "requestedType")?,
field_str(&args, "language")?,
))
}
"tmdbBulkVideosToTrailers" => opt_json(tmdb_plan::tmdb_bulk_videos_to_trailers_json(args_json)),
"tmdbResolveIdHint" => {
let (content_type, is_movie) = tmdb_plan::tmdb_resolve_id_hint(&arg_str(args_json, "contentId")?);
Ok(json!([content_type, is_movie]))
}
// Intro segments — args_json IS the data JSON for single-arg methods
"parseIntroDbSegments" => opt_json(intro_segments::parse_intro_db_segments_json(args_json)),
"parseAniskipResults" => opt_json(intro_segments::parse_aniskip_results_json(args_json)),
"uniqueIntroSegments" => {
let args = object(args_json)?;
opt_json(intro_segments::unique_intro_segments_json(
field_str(&args, "segmentsAJson")?,
field_str(&args, "segmentsBJson")?,
))
}
"mergeIntroSegments" => opt_json(intro_segments::merge_intro_segments_json(args_json)),
// Core contract
"coreCapabilities" => into_json(core_contract::core_capabilities_json(
object(args_json).ok().and_then(|o| o.get("portable").and_then(Value::as_bool)).unwrap_or(false),
)),
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
}
}
fn opt_json(value: Option<String>) -> Outcome {
Ok(match value {
Some(s) => serde_json::from_str(&s)
.map_err(|e| fail(ErrorKind::Internal, format!("core produced invalid JSON: {e}")))?,
None => Value::Null,
})
}
fn object(args_json: &str) -> Result<Value, CallError> {
let value: Value = serde_json::from_str(args_json)
.map_err(|e| fail(ErrorKind::InvalidArgs, format!("args is not valid JSON: {e}")))?;
if value.is_object() {
Ok(value)
} else {
Err(fail(ErrorKind::InvalidArgs, "args must be a JSON object"))
}
}
fn arg_str(args_json: &str, name: &str) -> Result<String, CallError> {
let args = object(args_json)?;
Ok(field_str(&args, name)?.to_string())
}
fn field<'a>(args: &'a Value, name: &str) -> Result<&'a Value, CallError> {
args.get(name)
.ok_or_else(|| fail(ErrorKind::InvalidArgs, format!("missing field `{name}`")))
}
fn field_str<'a>(args: &'a Value, name: &str) -> Result<&'a str, CallError> {
field(args, name)?
.as_str()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, format!("field `{name}` must be a string")))
}
fn field_u64(args: &Value, name: &str) -> Result<u64, CallError> {
field(args, name)?.as_u64().ok_or_else(|| {
fail(ErrorKind::InvalidArgs, format!("field `{name}` must be a non-negative integer"))
})
}
fn handle(args_json: &str) -> Result<u64, CallError> {
let value: Value = serde_json::from_str(args_json)
.map_err(|e| fail(ErrorKind::InvalidArgs, format!("args is not valid JSON: {e}")))?;
value
.as_u64()
.or_else(|| value.get("handle").and_then(Value::as_u64))
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "expected a handle (number or { handle })"))
}
fn result_json(value: Option<String>, method: &str) -> Outcome {
match value {
Some(s) => into_json(s),
None => Err(fail(ErrorKind::NotFound, format!("`{method}` produced no result"))),
}
}
fn into_json(s: String) -> Outcome {
serde_json::from_str(&s)
.map_err(|e| fail(ErrorKind::Internal, format!("core produced invalid JSON: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(s: &str) -> Value {
serde_json::from_str(s).unwrap()
}
#[test]
fn unknown_method_reports_kind_and_name() {
let env = parse(&core_invoke("nope.doesNotExist", "{}"));
assert_eq!(env["ok"], json!(false));
assert_eq!(env["error"]["kind"], json!("unknown_method"));
assert_eq!(env["error"]["method"], json!("nope.doesNotExist"));
}
#[test]
fn invalid_args_distinguished_from_empty_result() {
let bad_json = parse(&core_invoke("identity", "{ not json"));
assert_eq!(bad_json["error"]["kind"], json!("invalid_args"));
let missing_field = parse(&core_invoke("identity", "{}"));
assert_eq!(missing_field["error"]["kind"], json!("invalid_args"));
}
#[test]
fn stateless_helper_returns_ok_value() {
let env = parse(&core_invoke("parseVideoId", r#"{"id":"tt123:1:2"}"#));
assert_eq!(env["ok"], json!(true));
assert_eq!(env["value"]["imdb"], json!("tt123"));
assert_eq!(env["value"]["isEpisode"], json!(true));
}
#[test]
fn engine_roundtrips_through_the_funnel() {
let created = parse(&core_invoke("engine.create", "{}"));
let h = created["value"].as_i64().unwrap();
assert!(h > 0);
let snap = parse(&core_invoke("engine.snapshot", &h.to_string()));
assert_eq!(snap["ok"], json!(true));
let destroyed = parse(&core_invoke("engine.destroy", &h.to_string()));
assert_eq!(destroyed["ok"], json!(true));
assert_eq!(destroyed["value"], json!(true));
}
}

View file

@ -57,6 +57,11 @@ pub(super) enum AppAction {
language: Option<String>,
profile: Option<Value>,
},
#[serde(rename = "detailStreamsAppended")]
DetailStreamsAppended {
streams: Vec<Value>,
available_addons: Vec<String>,
},
#[serde(rename = "detailSelectedAddonChanged")]
DetailSelectedAddonChanged { addon: Option<String> },
#[serde(rename = "metaDetailRequested")]

View file

@ -159,6 +159,47 @@ pub(super) fn dispatch_streams(
)]
}
pub(super) fn dispatch_streams_appended(
engine: &mut HeadlessEngine,
streams: Vec<Value>,
available_addons: Vec<String>,
) -> Vec<EffectEnvelope> {
if !engine.state["detail"]["isLoadingStreams"]
.as_bool()
.unwrap_or(false)
{
return vec![];
}
let mut merged: Vec<Value> = engine.state["detail"]["streams"]
.as_array()
.cloned()
.unwrap_or_default();
merged.extend(streams);
engine.state["detail"]["streams"] = json!(merged);
engine.state["detail"]["visibleStreams"] = visible_streams(
&engine.state["detail"]["streams"],
engine.state["detail"]["selectedAddon"].as_str(),
);
let mut all_addons: Vec<String> = engine.state["detail"]["availableAddons"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
for addon in available_addons {
if !all_addons.contains(&addon) {
all_addons.push(addon);
}
}
engine.state["detail"]["availableAddons"] = json!(all_addons);
engine.state["detail"]["hasStreamProviders"] = json!(!value_array_is_empty(
&engine.state["detail"]["streams"]
));
vec![]
}
pub(super) fn dispatch_selected_addon_changed(
engine: &mut HeadlessEngine,
addon: Option<String>,

View file

@ -158,6 +158,10 @@ impl HeadlessEngine {
language,
profile,
),
AppAction::DetailStreamsAppended {
streams,
available_addons,
} => detail::dispatch_streams_appended(self, streams, available_addons),
AppAction::DetailSelectedAddonChanged { addon } => {
detail::dispatch_selected_addon_changed(self, addon)
}
@ -567,6 +571,8 @@ impl HeadlessEngine {
}
fn result_json(&self, effects: Vec<EffectEnvelope>) -> Option<String> {
// When a complete_effect handler produces no new effects, we return all
// remaining pendingEffects so the platform can drain the queue in one pass.
let visible_effects = if effects.is_empty() {
self.state["pendingEffects"]
.as_array()

View file

@ -1,4 +1,5 @@
use crate::content_identity::{imdb_id, normalized_billboard_title};
use crate::search_plan::resolve_transport_url_json;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
@ -789,10 +790,7 @@ pub(crate) fn build_billboard_pool_json(
fn iso_date_part(date_str: &str) -> Option<&str> {
let s = date_str.trim();
if s.len() < 10 {
return None;
}
let date_part = &s[..10];
let date_part = s.get(..10)?;
let b = date_part.as_bytes();
if b[4] == b'-' && b[7] == b'-' {
Some(date_part)
@ -845,3 +843,146 @@ pub(crate) fn normalize_home_catalog_items_json(
serde_json::to_string(&result).ok()
}
pub(crate) fn build_home_collection_shelves_json(profile_json: &str, addons_json: &str) -> Option<String> {
let profile: Value = serde_json::from_str(profile_json).ok()?;
let collections = match profile.get("libraryCollections").and_then(Value::as_array) {
Some(c) => c,
None => return serde_json::to_string(&json!({ "pinnedShelves": [], "regularShelves": [], "hiddenFolderCategories": [] })).ok(),
};
let mut pinned: Vec<Value> = Vec::new();
let mut regular: Vec<Value> = Vec::new();
let mut hidden: Vec<Value> = Vec::new();
for (ci, col) in collections.iter().enumerate() {
let c = match col.as_object() {
Some(o) => o,
None => continue,
};
if !c.get("showOnHome").and_then(Value::as_bool).unwrap_or(false) {
continue;
}
let folders = c.get("folders").and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]);
if folders.is_empty() {
continue;
}
let mut tiles: Vec<Value> = Vec::new();
for (fi, f) in folders.iter().enumerate() {
let folder = match f.as_object() {
Some(o) => o,
None => continue,
};
let folder_title = folder.get("title").and_then(Value::as_str).unwrap_or("").to_string();
if folder_title.is_empty() {
continue;
}
let folder_id = folder.get("id").and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| format!("col{ci}_f{fi}"));
let mut resolved: Vec<Value> = Vec::new();
if let Some(sources) = folder.get("catalogSources").and_then(Value::as_array) {
for s in sources {
if s.get("catalogId").and_then(Value::as_str).is_none() {
continue;
}
if let Some(t_url) = resolve_transport_url_json(&s.to_string(), addons_json) {
let catalog_id = s.get("catalogId").and_then(Value::as_str).unwrap_or("");
let content_type = s.get("type").and_then(Value::as_str).unwrap_or("movie");
let mut entry = json!({ "transportUrl": t_url, "catalogId": catalog_id, "type": content_type });
if let Some(g) = folder.get("genre").and_then(Value::as_str) {
entry["genre"] = Value::String(g.to_string());
}
resolved.push(entry);
}
}
}
if resolved.is_empty() {
if let Some(catalog_id) = folder.get("catalogId").and_then(Value::as_str) {
let src = json!({ "catalogId": catalog_id, "type": "movie" });
if let Some(t_url) = resolve_transport_url_json(&src.to_string(), addons_json) {
let mut entry = json!({ "transportUrl": t_url, "catalogId": catalog_id, "type": "movie" });
if let Some(g) = folder.get("genre").and_then(Value::as_str) {
entry["genre"] = Value::String(g.to_string());
}
resolved.push(entry);
}
}
}
if !resolved.is_empty() {
let mut hcat = json!({
"id": folder_id,
"name": folder_title,
"type": "collection_folder",
"items": [],
"catalogSources": resolved,
"canLoadMore": false,
});
if let Some(g) = folder.get("genre").and_then(Value::as_str) {
hcat["addonGenre"] = Value::String(g.to_string());
}
hidden.push(hcat);
}
let img_url = folder.get("coverImageUrl").and_then(Value::as_str)
.or_else(|| folder.get("imageUrl").and_then(Value::as_str))
.unwrap_or("");
let bg_url = folder.get("heroBackdropUrl").and_then(Value::as_str).unwrap_or(img_url);
let focus_gif_enabled = folder.get("focusGifEnabled").and_then(Value::as_bool).unwrap_or(true);
let mut tile = json!({
"id": folder_id,
"type": "catalog_folder",
"name": folder_title,
"poster": if img_url.is_empty() { Value::Null } else { Value::String(img_url.to_string()) },
"background": if bg_url.is_empty() { Value::Null } else { Value::String(bg_url.to_string()) },
"reason": folder.get("shape").and_then(Value::as_str).unwrap_or("poster"),
});
if let Some(logo) = folder.get("titleLogoUrl").and_then(Value::as_str) {
tile["logo"] = Value::String(logo.to_string());
}
if let Some(info) = folder.get("catalogTitle").and_then(Value::as_str) {
tile["releaseInfo"] = Value::String(info.to_string());
}
if focus_gif_enabled {
if let Some(gif) = folder.get("focusGifUrl").and_then(Value::as_str) {
tile["focusGifUrl"] = Value::String(gif.to_string());
}
}
tiles.push(tile);
}
if tiles.is_empty() {
continue;
}
let shelf_id = c.get("id").and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| format!("col{ci}"));
let shelf = json!({
"id": shelf_id,
"name": c.get("title").and_then(Value::as_str).unwrap_or(""),
"type": "collection",
"items": tiles,
"canLoadMore": false,
});
if c.get("pinToTop").and_then(Value::as_bool).unwrap_or(false) {
pinned.push(shelf);
} else {
regular.push(shelf);
}
}
serde_json::to_string(&json!({
"pinnedShelves": pinned,
"regularShelves": regular,
"hiddenFolderCategories": hidden,
})).ok()
}

View file

@ -69,8 +69,8 @@ fn segment_from_object(obj: &serde_json::Map<String, Value>) -> Option<Value> {
fn segment_from_object_with_type(value: &Value, fallback_type: &str) -> Option<Value> {
let obj = value.as_object()?;
let start = number_from_keys(obj, &["startTime", "start", "from", "start_time"])?;
let end = number_from_keys(obj, &["endTime", "end", "to", "end_time"])?;
let start = number_from_keys(obj, &["startTime", "start", "from", "start_time", "start_sec", "startTimeMs", "start_ms"])?;
let end = number_from_keys(obj, &["endTime", "end", "to", "end_time", "end_sec", "endTimeMs", "end_ms"])?;
let raw_type = string_from_keys(obj, &["type", "segment_type"]).unwrap_or_else(|| fallback_type.to_string());
let seg_type = normalize_skip_type(&raw_type);
let start_ms = normalize_time(start);
@ -117,10 +117,6 @@ pub(crate) fn normalize_skip_type(raw: &str) -> &'static str {
}
}
pub(crate) fn normalize_skip_time(seconds: f64) -> i64 {
normalize_time(seconds)
}
pub(crate) fn parse_aniskip_results_json(results_json: &str) -> Option<String> {
let results: Value = serde_json::from_str(results_json).ok()?;
let items = results.get("results").and_then(Value::as_array)?;

View file

@ -1,3 +1,4 @@
#[cfg(feature = "uniffi-bindings")]
uniffi::setup_scaffolding!();
mod addon_protocol;
@ -10,6 +11,7 @@ pub mod core_api;
pub mod core_contract;
mod data_policy;
mod discovery_plan;
#[cfg(feature = "native")]
mod dolby_vision_rpu;
mod external_sync;
mod headless_adapter_plan;
@ -32,6 +34,7 @@ mod watchlist_plan;
pub mod addon_transport;
pub mod env;
pub mod ffi;
pub mod runtime;
pub mod types;

View file

@ -435,7 +435,11 @@ pub(crate) fn compute_continue_watching_badges_json(
"lastEpisodeName": next.get("name").or_else(|| next.get("title")).cloned().unwrap_or(Value::Null),
"lastEpisodeSeason": next.get("season").cloned().unwrap_or(Value::Null),
"lastEpisodeNumber": next.get("episode").or_else(|| next.get("number")).cloned().unwrap_or(Value::Null),
"lastEpisodeThumbnail": next.get("thumbnail").cloned().unwrap_or(Value::Null),
"lastEpisodeThumbnail": next.get("thumbnail")
.filter(|v| !v.is_null())
.cloned()
.or_else(|| if !is_new_target { candidate.get("lastEpisodeThumbnail").cloned().filter(|v| !v.is_null()) } else { None })
.unwrap_or(Value::Null),
"continueWatchingBadge": badge,
"newEpisodeReleasedAt": released_str,
"savedAt": saved_at_new,
@ -515,6 +519,122 @@ pub(crate) fn remember_last_watched_episodes_json(lib_json: &str, watched_ids_js
serde_json::to_string(&lib).unwrap_or_else(|_| lib_json.to_string())
}
/// Returns the next episode after (current_season, current_episode).
/// If released_only is true, episodes whose `released` date is in the future
/// (relative to now_ms) are excluded.
pub(crate) fn resolve_next_episode_json(
videos_json: &str,
current_season: i64,
current_episode: i64,
now_ms: i64,
released_only: bool,
) -> Option<String> {
let videos: Vec<Value> = serde_json::from_str(videos_json).ok()?;
let filtered: Vec<&Value> = videos
.iter()
.filter(|v| released_only || is_episode_released(v, now_ms))
.collect();
let next = first_episode_after(
&filtered.into_iter().cloned().collect::<Vec<_>>(),
current_season,
current_episode,
)?;
serde_json::to_string(&next).ok()
}
/// Formats a "S1:E2 Episode Name" line from the episode progress fields.
/// Falls back to parsing season/episode from lastVideoId when the explicit
/// season/episode numbers are absent.
pub(crate) fn format_episode_line_json(
last_episode_name: Option<&str>,
last_episode_season: Option<i64>,
last_episode_number: Option<i64>,
last_video_id: Option<&str>,
) -> String {
let mut season = last_episode_season;
let mut episode = last_episode_number;
if (season.is_none() || episode.is_none()) && last_video_id.is_some_and(|id| !id.is_empty()) {
let parts: Vec<&str> = last_video_id.unwrap().split(':').collect();
if parts.len() >= 3 {
if let (Ok(s), Ok(e)) = (
parts[parts.len() - 2].parse::<i64>(),
parts[parts.len() - 1].parse::<i64>(),
) {
if s > 0 && e > 0 {
if season.is_none() { season = Some(s); }
if episode.is_none() { episode = Some(e); }
}
}
}
}
let code = match (season, episode) {
(Some(s), Some(e)) => format!("S{s}:E{e}"),
_ => String::new(),
};
let name = last_episode_name.map(str::trim).unwrap_or("").to_string();
[code, name]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
/// Selects the best artwork URL for a continue-watching card.
/// `artwork_preference` is "poster", "background", or "episode" (default).
/// `is_horizontal` controls whether the card layout is wide/horizontal.
pub(crate) fn select_continue_watching_artwork_json(
item_json: &str,
artwork_preference: &str,
is_horizontal: bool,
) -> Option<String> {
let item: Value = serde_json::from_str(item_json).ok()?;
let str_field = |key: &str| -> Option<String> {
item.get(key)
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.map(str::to_string)
};
let poster = str_field("poster");
let background = str_field("background");
let logo = str_field("logo");
let thumbnail = str_field("lastEpisodeThumbnail");
let cw_poster = str_field("continueWatchingPoster");
let cw_background = str_field("continueWatchingBackground");
let is_real_backdrop = background.as_deref().is_some_and(|bg| {
poster.as_deref().map_or(true, |p| bg != p)
&& !bg.to_lowercase().contains("/poster/")
});
let existing_backdrop = if is_real_backdrop { background.clone() } else { None };
let result = if !is_horizontal {
thumbnail
.or(cw_poster)
.or(poster)
.or(cw_background)
.or(background)
} else {
let content_type = item.get("type").and_then(Value::as_str).unwrap_or("");
let is_series = matches!(content_type, "series" | "tv" | "anime");
let _ = is_series;
match artwork_preference {
"poster" => poster.or(cw_background).or(existing_backdrop),
"background" => existing_backdrop.or(cw_background).or(poster),
_ => thumbnail
.or(cw_background)
.or(existing_backdrop)
.or(background)
.or(logo)
.or(poster),
}
};
result
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -652,3 +652,37 @@ fn addon_key(addon: &Value) -> String {
.unwrap_or("")
.to_string()
}
/// Maps a request `kind` to the addon resource name used in URLs and responses.
/// `request_resource` and `item_resource` are optional overrides from the request.
pub(crate) fn resource_kind_to_resource(kind: &str, request_resource: Option<&str>, item_resource: Option<&str>) -> String {
let explicit = item_resource
.filter(|s| !s.trim().is_empty())
.or_else(|| request_resource.filter(|s| !s.trim().is_empty()));
if let Some(r) = explicit {
return r.to_string();
}
match kind {
"catalogPage" | "discover" | "search" => "catalog",
"metaDetail" | "seasonEpisodes" => "meta",
"streams" => "stream",
"subtitles" => "subtitles",
other if !other.trim().is_empty() => other,
_ => "catalog",
}
.to_string()
}
/// Wraps an addon resource payload in the conventional response envelope
/// used by `coreResourceParsePlan`.
pub(crate) fn wrap_addon_resource_response(resource: &str, payload_json: &str) -> String {
let payload: Value = serde_json::from_str(payload_json).unwrap_or(Value::Null);
let wrapped = match resource {
"catalog" | "metas" => json!({ "metas": payload }),
"stream" | "streams" => json!({ "streams": payload }),
"meta" => json!({ "meta": payload }),
"subtitle" | "subtitles" => json!({ "subtitles": payload }),
_ => payload,
};
serde_json::to_string(&wrapped).unwrap_or_else(|_| "{}".to_string())
}

View file

@ -567,8 +567,13 @@ pub(crate) fn dv_proxy_plan_json(request_json: &str) -> Option<String> {
"p10_compat_id_1_hdr10_base",
vec!["does_not_convert_bitstream", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"],
),
// P4, P5, P10Other, Unknown already returned above.
_ => unreachable!(),
_ => (
"dvcc_strip",
"HDR10_assumed",
"medium",
"unknown_profile_dvcc_strip_fallback",
vec!["header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"],
),
};
plan_rich(action, reason, profile.label(), compat, safety, &limitations)
@ -801,6 +806,79 @@ fn episode_path_matches_id(path: &str, video_id: &str) -> bool {
|| path_lower.contains(&pattern_ep)
}
/// Returns true when the player should attempt to pre-fetch the next episode's
/// stream list. Uses the same binge-group / auto-selection rules as the desktop.
pub(crate) fn can_prefetch_next_episode_json(prefs_json: &str, stream_json: &str) -> bool {
let prefs: Value = serde_json::from_str(prefs_json).unwrap_or(Value::Null);
let stream: Value = serde_json::from_str(stream_json).unwrap_or(Value::Null);
let try_binge = prefs.get("tryBingeGroup").and_then(Value::as_bool).unwrap_or(false);
let mode = prefs
.get("streamSourceSelectionMode")
.and_then(Value::as_str)
.unwrap_or("manual");
let has_binge_group = stream
.get("behaviorHints")
.and_then(|h| h.get("bingeGroup"))
.and_then(Value::as_str)
.map_or(false, |s| !s.is_empty());
(try_binge && has_binge_group) || mode != "manual"
}
/// Selects the best stream from `streams_json` for the next episode given the
/// current stream and playback preferences. Returns the selected stream as JSON,
/// or `null` if none qualifies.
pub(crate) fn select_next_episode_stream_json(
streams_json: &str,
current_stream_json: &str,
prefs_json: &str,
) -> Option<String> {
let streams: Vec<Value> = serde_json::from_str(streams_json).ok()?;
if streams.is_empty() { return None; }
let current: Value = serde_json::from_str(current_stream_json).ok()?;
let prefs: Value = serde_json::from_str(prefs_json).unwrap_or(Value::Null);
let try_binge = prefs.get("tryBingeGroup").and_then(Value::as_bool).unwrap_or(false);
let mode = prefs.get("streamSourceSelectionMode").and_then(Value::as_str).unwrap_or("manual");
let regex_pat = prefs.get("streamSourceRegexPattern").and_then(Value::as_str).unwrap_or("");
let cur_binge = current
.get("behaviorHints")
.and_then(|h| h.get("bingeGroup"))
.and_then(Value::as_str)
.filter(|s| !s.is_empty());
if try_binge {
if let Some(group) = cur_binge {
let matched = streams.iter().find(|s| {
s.get("behaviorHints")
.and_then(|h| h.get("bingeGroup"))
.and_then(Value::as_str)
== Some(group)
});
if let Some(s) = matched {
return serde_json::to_string(s).ok();
}
}
}
if mode == "regex" && !regex_pat.is_empty() {
if let Ok(re) = regex::RegexBuilder::new(regex_pat).case_insensitive(true).build() {
let stream_text = |s: &Value| -> String {
[s.get("name"), s.get("title"), s.get("description"), s.get("url"), s.get("playableUrl"), s.get("infoHash")]
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect::<Vec<_>>()
.join(" ")
};
if let Some(matched) = streams.iter().find(|s| re.is_match(&stream_text(s))) {
return serde_json::to_string(matched).ok();
}
}
}
streams.first().and_then(|s| serde_json::to_string(s).ok())
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -59,6 +59,57 @@ fn has_token(token: Option<&str>) -> bool {
token.is_some_and(|value| !value.trim().is_empty())
}
pub(crate) fn trakt_scrobble_plan_json(
ids_json: &str,
is_episode: bool,
season: Option<i64>,
ep_number: Option<i64>,
time_pos_sec: f64,
duration_sec: f64,
) -> Option<String> {
let ids: serde_json::Value = serde_json::from_str(ids_json).ok()?;
if duration_sec <= 0.0 {
return None;
}
let progress = ((time_pos_sec / duration_sec) * 100.0).clamp(0.0, 100.0);
let action = if progress as f32 >= SCROBBLE_STOP_PROGRESS_PERCENT { "stop" } else { "pause" };
let body = if is_episode {
serde_json::json!({
"show": { "ids": ids },
"episode": { "season": season.unwrap_or(1), "number": ep_number.unwrap_or(1) },
"progress": progress
})
} else {
serde_json::json!({ "movie": { "ids": ids }, "progress": progress })
};
serde_json::to_string(&serde_json::json!({ "action": action, "body": body })).ok()
}
pub(crate) fn simkl_scrobble_body_json(
ids_json: &str,
is_episode: bool,
season: i64,
ep_number: i64,
time_pos_sec: f64,
duration_sec: f64,
) -> Option<String> {
let ids: serde_json::Value = serde_json::from_str(ids_json).ok()?;
if duration_sec <= 0.0 {
return None;
}
let progress = ((time_pos_sec / duration_sec) * 100.0).clamp(0.0, 100.0);
let body = if is_episode {
serde_json::json!({
"show": { "ids": ids },
"episode": { "season": season, "number": ep_number },
"progress": progress
})
} else {
serde_json::json!({ "movie": { "ids": ids }, "progress": progress })
};
serde_json::to_string(&body).ok()
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -347,7 +347,11 @@ pub(crate) fn discover_sort_plan_json(request_json: &str) -> Option<String> {
let request = serde_json::from_str::<DiscoverSortRequest>(request_json).ok()?;
let content_type = request.content_type_filter.as_deref().unwrap_or("");
let genre = request.genre_filter.as_deref().unwrap_or("").to_lowercase();
let sort_by = request.sort_by.as_deref().unwrap_or("default");
let sort_by = match request.sort_by.as_deref().unwrap_or("default") {
"top" => "rating",
"newest" => "year",
other => other,
};
let mut filtered: Vec<&Value> = request
.items
@ -535,6 +539,83 @@ pub(crate) fn detail_season_load_plan_json(request_json: &str) -> Option<String>
.ok()
}
/// Given a catalog source `{catalogId, type, addonId?}` and an array of addon
/// descriptors, returns the first matching `transportUrl`, or `null`.
pub(crate) fn resolve_transport_url_json(source_json: &str, addons_json: &str) -> Option<String> {
let source: Value = serde_json::from_str(source_json).ok()?;
let addons: Vec<Value> = serde_json::from_str(addons_json).ok()?;
let src_addon_id = source.get("addonId").and_then(Value::as_str).map(str::to_lowercase);
let src_catalog_id = source.get("catalogId").and_then(Value::as_str)?;
let normalize_type = |v: &str| -> String {
match v.trim().to_lowercase().as_str() {
"movies" => "movie".to_string(),
"series" | "tv" | "show" | "shows" => "series".to_string(),
other => other.to_string(),
}
};
let src_type = source.get("type").and_then(Value::as_str).map(normalize_type);
for addon in &addons {
let manifest = addon.get("manifest")?;
let addon_id = manifest.get("id").and_then(Value::as_str).unwrap_or("").to_lowercase();
let t_url = addon.get("transportUrl").and_then(Value::as_str).unwrap_or("");
if let Some(ref wanted_addon_id) = src_addon_id {
if !(addon_id == *wanted_addon_id || t_url.to_lowercase().contains(wanted_addon_id.as_str())) {
continue;
}
}
let catalogs = manifest.get("catalogs").and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]);
let matches = catalogs.iter().any(|cat| {
cat.get("id").and_then(Value::as_str) == Some(src_catalog_id)
&& src_type.as_deref().map_or(true, |st| {
cat.get("type").and_then(Value::as_str).map(|ct| normalize_type(ct)) == Some(st.to_string())
})
});
if matches {
return Some(t_url.to_string());
}
}
None
}
/// Resolves the effective genre for a metadata feed option by inspecting the
/// corresponding catalog's `extra` array for a `genre` field with a default or
/// first required value.
pub(crate) fn resolve_feed_option_genre_json(feed_option_json: &str, addons_json: &str) -> Option<String> {
let option: Value = serde_json::from_str(feed_option_json).ok()?;
let addons: Vec<Value> = serde_json::from_str(addons_json).ok()?;
// If genre is already set on the option, return it.
if let Some(genre) = option.get("genre").and_then(Value::as_str).filter(|s| !s.trim().is_empty()) {
return Some(genre.to_string());
}
let transport_url = option.get("transportUrl").and_then(Value::as_str)?;
let opt_type = option.get("type").and_then(Value::as_str)?;
let opt_id = option.get("id").and_then(Value::as_str)?;
let addon = addons.iter().find(|a| a.get("transportUrl").and_then(Value::as_str) == Some(transport_url))?;
let catalogs = addon.get("manifest").and_then(|m| m.get("catalogs")).and_then(Value::as_array)?;
let catalog = catalogs.iter().find(|cat| {
cat.get("type").and_then(Value::as_str) == Some(opt_type)
&& cat.get("id").and_then(Value::as_str) == Some(opt_id)
})?;
let extras = catalog.get("extra").and_then(Value::as_array)?;
let genre_extra = extras.iter().find(|e| e.get("name").and_then(Value::as_str) == Some("genre"))?;
let default_genre = genre_extra.get("default").and_then(Value::as_str).filter(|s| !s.trim().is_empty());
let is_required = genre_extra.get("isRequired").and_then(Value::as_bool).unwrap_or(false);
let first_option = genre_extra.get("options").and_then(Value::as_array)
.and_then(|opts| opts.first())
.and_then(Value::as_str);
let resolved = default_genre
.or_else(|| if is_required { first_option } else { None })?;
Some(resolved.to_string())
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -40,7 +40,7 @@ pub(crate) fn tmdb_meta_to_meta_json(item_json: &str, requested_type: &str, lang
"name": name,
"poster": poster,
"background": background,
"releaseInfo": released.map(|r| &r[..4.min(r.len())]),
"releaseInfo": released.map(|r| r.get(..4).unwrap_or(r)),
})).ok()?)
}

View file

@ -186,6 +186,196 @@ pub(crate) fn playback_progress_merge_plan_json(request_json: &str) -> Option<St
.ok()
}
// ── Collections import/export ─────────────────────────────────────────────────
fn cleaned_url(raw: Option<&str>) -> Option<String> {
raw.map(str::trim).filter(|s| !s.is_empty()).map(str::to_string)
}
fn cleaned_artwork_url(raw: Option<&str>) -> Option<String> {
let s = raw?.trim().trim_matches('\'').trim_matches('"').trim();
if s.is_empty() { return None; }
let with_scheme = if s.starts_with("//") { format!("https:{s}") } else { s.to_string() };
let normalized = if let Some(caps) = regex::Regex::new(
r"^https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.+)$"
).ok().and_then(|re| re.captures(&with_scheme)) {
format!("https://raw.githubusercontent.com/{}/{}/{}/{}",
&caps[1], &caps[2], &caps[3], &caps[4])
} else {
with_scheme
};
Some(normalized.replace(' ', "%20"))
}
fn pick_str<'a>(obj: &'a serde_json::Map<String, Value>, keys: &[&str]) -> Option<&'a str> {
for k in keys { if let Some(Value::String(s)) = obj.get(*k) { return Some(s.as_str()); } }
None
}
fn normalize_shape(value: Option<&str>) -> &'static str {
match value.map(|s| s.trim().to_uppercase()).as_deref() {
Some("LANDSCAPE") | Some("WIDE") => "wide",
Some("SQUARE") => "square",
_ => "poster",
}
}
fn export_shape(value: Option<&str>) -> &'static str {
match value.map(str::to_lowercase).as_deref() {
Some("wide") | Some("landscape") => "LANDSCAPE",
Some("square") => "SQUARE",
_ => "POSTER",
}
}
/// Parses a raw JSON string (array or single object) of user collections in the
/// portable cross-platform exchange format and normalises it into the internal
/// `UserCollection[]` representation used by the desktop/Android apps.
// FNV-1a over the title: a wasm-safe, deterministic id suffix for imported
// entries that arrive without one (re-importing the same file is idempotent).
fn stable_suffix(seed: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in seed.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
pub(crate) fn import_collections_json(raw_json: &str) -> Option<String> {
let parsed: Value = serde_json::from_str(raw_json).ok()?;
let arr: Vec<&Value> = if parsed.is_array() {
parsed.as_array().unwrap().iter().collect()
} else {
vec![&parsed]
};
let collections: Vec<Value> = arr.iter().enumerate().filter_map(|(i, col)| {
let col = col.as_object()?;
let title = col.get("title")?.as_str()?.trim().to_string();
if title.is_empty() { return None; }
let id = col.get("id").and_then(Value::as_str).filter(|s| !s.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("imported_{}_{i}", stable_suffix(&title)));
let raw_folders = col.get("folders").and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]);
let folders: Vec<Value> = raw_folders.iter().enumerate().filter_map(|(fi, f)| {
let folder = f.as_object()?;
let folder_title = folder.get("title")?.as_str()?.trim().to_string();
if folder_title.is_empty() { return None; }
let fid = folder.get("id").and_then(Value::as_str).filter(|s| !s.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("folder_{}_{fi}", stable_suffix(&folder_title)));
let raw_sources = folder.get("catalogSources").and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]);
let mut sources: Vec<Value> = raw_sources.iter().filter_map(|s| {
let o = s.as_object()?;
let catalog_id = o.get("catalogId")?.as_str().filter(|s| !s.is_empty())?;
Some(json!({
"catalogId": catalog_id,
"type": o.get("type").and_then(Value::as_str).unwrap_or("movie"),
"addonId": o.get("addonId").and_then(Value::as_str),
}))
}).collect();
if sources.is_empty() {
if let Some(fallback_id) = folder.get("catalogId").and_then(Value::as_str).filter(|s| !s.is_empty()) {
sources.push(json!({ "catalogId": fallback_id, "type": "movie" }));
}
}
let cover_image_url = cleaned_artwork_url(pick_str(folder, &["coverImageUrl","coverUrl","coverImage","cover","poster","thumbnail","thumb"]));
let image_url = cleaned_artwork_url(pick_str(folder, &["imageUrl","image","image_url","posterUrl","poster_url"]));
let effective_cover = cover_image_url.or(image_url);
let hero_backdrop_url = cleaned_url(pick_str(folder, &["heroBackdropUrl","background","backdrop","backgroundUrl","backdropUrl"]));
let shape = normalize_shape(folder.get("tileShape").or(folder.get("shape")).and_then(Value::as_str));
Some(json!({
"id": fid,
"title": folder_title,
"catalogTitle": folder.get("catalogTitle").and_then(Value::as_str).unwrap_or(&folder_title),
"catalogId": sources.first().and_then(|s| s.get("catalogId")).and_then(Value::as_str),
"genre": folder.get("genre").and_then(Value::as_str),
"shape": shape,
"hideTitle": folder.get("hideTitle").and_then(Value::as_bool).unwrap_or(false),
"focusGifEnabled": folder.get("focusGifEnabled").and_then(Value::as_bool).unwrap_or(true),
"catalogSources": if sources.is_empty() { Value::Null } else { json!(sources) },
"coverEmoji": folder.get("coverEmoji").and_then(Value::as_str),
"imageUrl": effective_cover,
"coverImageUrl": effective_cover,
"focusGifUrl": cleaned_url(folder.get("focusGifUrl").and_then(Value::as_str)),
"titleLogoUrl": cleaned_url(folder.get("titleLogoUrl").and_then(Value::as_str)),
"heroBackdropUrl": hero_backdrop_url,
}))
}).collect();
let first_folder_cover = raw_folders.first()
.and_then(|f| f.as_object())
.and_then(|f| cleaned_artwork_url(pick_str(f, &["coverImageUrl","coverUrl","coverImage","cover","poster","thumbnail","thumb"]))
.or_else(|| cleaned_artwork_url(pick_str(f, &["imageUrl","image","image_url","posterUrl","poster_url"]))));
Some(json!({
"id": id,
"title": title,
"imageUrl": first_folder_cover,
"showOnHome": col.get("showOnHome").and_then(Value::as_bool).unwrap_or(true),
"itemIds": [],
"folders": folders,
"showAllTab": col.get("showAllTab").and_then(Value::as_bool).unwrap_or(true),
"viewMode": col.get("viewMode").and_then(Value::as_str).unwrap_or("FOLLOW_LAYOUT"),
"pinToTop": col.get("pinToTop").and_then(Value::as_bool).unwrap_or(false),
"focusGlowEnabled": col.get("focusGlowEnabled").and_then(Value::as_bool).unwrap_or(true),
}))
}).collect();
serde_json::to_string(&collections).ok()
}
/// Serialises the internal `UserCollection[]` into the portable exchange format.
pub(crate) fn export_collections_json(collections_json: &str) -> Option<String> {
let collections: Vec<Value> = serde_json::from_str(collections_json).ok()?;
let data: Vec<Value> = collections.iter().map(|col| {
let folders_raw = col.get("folders").and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]);
let folders: Vec<Value> = folders_raw.iter().map(|folder| {
let catalog_sources: Vec<Value> = folder.get("catalogSources")
.and_then(Value::as_array)
.filter(|arr| !arr.is_empty())
.map(|arr| arr.clone())
.unwrap_or_else(|| {
if let Some(cid) = folder.get("catalogId").and_then(Value::as_str) {
vec![json!({ "catalogId": cid, "type": "movie" })]
} else {
vec![]
}
});
json!({
"id": folder.get("id"),
"title": folder.get("title"),
"tileShape": export_shape(folder.get("shape").and_then(Value::as_str)),
"hideTitle": folder.get("hideTitle").and_then(Value::as_bool).unwrap_or(false),
"focusGifEnabled": folder.get("focusGifEnabled").and_then(Value::as_bool).unwrap_or(true),
"catalogSources": catalog_sources,
"coverEmoji": folder.get("coverEmoji"),
"coverImageUrl": folder.get("coverImageUrl").or_else(|| folder.get("imageUrl")),
"focusGifUrl": folder.get("focusGifUrl"),
"titleLogoUrl": folder.get("titleLogoUrl"),
"heroBackdropUrl": folder.get("heroBackdropUrl"),
})
}).collect();
json!({
"id": col.get("id"),
"title": col.get("title"),
"showAllTab": col.get("showAllTab").and_then(Value::as_bool).unwrap_or(true),
"viewMode": col.get("viewMode").and_then(Value::as_str).unwrap_or("FOLLOW_LAYOUT"),
"showOnHome": col.get("showOnHome").and_then(Value::as_bool).unwrap_or(true),
"pinToTop": col.get("pinToTop").and_then(Value::as_bool).unwrap_or(false),
"focusGlowEnabled": col.get("focusGlowEnabled").and_then(Value::as_bool).unwrap_or(true),
"folders": folders,
})
}).collect();
serde_json::to_string(&data).ok()
}
#[cfg(test)]
mod tests {
use super::*;
@ -275,3 +465,65 @@ mod tests {
assert_eq!(result["videoChanged"], false);
}
}
pub(crate) fn library_apply_mark_watched_json(lib_json: &str, video_ids_json: &str) -> Option<String> {
use crate::library_state::{build_continue_watching_from_progress_json, remember_last_watched_episodes_json};
let updated_lib_str = remember_last_watched_episodes_json(lib_json, video_ids_json);
let mut lib: serde_json::Map<String, Value> = serde_json::from_str(&updated_lib_str).ok()?;
let video_ids: Vec<String> = serde_json::from_str(video_ids_json).unwrap_or_default();
let watched: std::collections::HashSet<&str> = video_ids.iter().map(String::as_str).collect();
if let Some(ext_cw) = lib.get("externalContinueWatching").and_then(Value::as_array).cloned() {
let filtered: Vec<Value> = ext_cw
.into_iter()
.filter(|item| {
let last_vid = item.get("lastVideoId").and_then(Value::as_str).unwrap_or("");
last_vid.is_empty() || !watched.contains(last_vid)
})
.collect();
lib.insert("externalContinueWatching".into(), filtered.into());
}
let progress_map = lib.get("progress").and_then(Value::as_object).cloned().unwrap_or_default();
let cleaned: serde_json::Map<String, Value> = progress_map
.into_iter()
.filter(|(_, entry)| {
let last_vid = entry.get("lastVideoId").and_then(Value::as_str).unwrap_or("");
last_vid.is_empty() || !watched.contains(last_vid)
})
.collect();
let progress_json = serde_json::to_string(&cleaned).unwrap_or_else(|_| "{}".to_string());
let cw = build_continue_watching_from_progress_json(&progress_json)
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
.unwrap_or_else(|| Value::Array(Vec::new()));
lib.insert("progress".into(), Value::Object(cleaned));
lib.insert("continueWatching".into(), cw);
serde_json::to_string(&Value::Object(lib)).ok()
}
pub(crate) fn merge_progress_meta_json(incoming_meta_json: &str, existing_meta_json: &str) -> String {
let incoming: Value = serde_json::from_str(incoming_meta_json).unwrap_or(json!({}));
let existing: Value = serde_json::from_str(existing_meta_json).unwrap_or(json!({}));
let pick = |key: &str| -> Value {
incoming
.get(key)
.filter(|v| !v.is_null())
.cloned()
.or_else(|| existing.get(key).cloned())
.unwrap_or(Value::Null)
};
let mut merged = incoming.clone();
if let Some(obj) = merged.as_object_mut() {
obj.insert("poster".into(), pick("poster"));
obj.insert("background".into(), pick("background"));
obj.insert("logo".into(), pick("logo"));
}
serde_json::to_string(&merged).unwrap_or_else(|_| incoming_meta_json.to_string())
}