mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-08-19 13:35:53 +00:00
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:
parent
b0c0b93b8e
commit
2f510a6a4b
23 changed files with 1920 additions and 97 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
12
Cargo.toml
12
Cargo.toml
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
#[cfg(feature = "native")]
|
||||
pub mod jni;
|
||||
#[cfg(feature = "uniffi-bindings")]
|
||||
pub mod uniffi;
|
||||
#[cfg(feature = "wasm")]
|
||||
pub mod wasm;
|
||||
|
|
|
|||
|
|
@ -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
11
src/bindings/wasm.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
135
src/core_api.rs
135
src/core_api.rs
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
683
src/ffi.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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()?)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue