feat: update desktop core API support

This commit is contained in:
KhooLy 2026-07-03 16:05:52 +03:00
parent 5fb580f782
commit b0bb52a6a3
55 changed files with 7190 additions and 2099 deletions

2658
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,22 @@
[workspace]
members = ["fluxa-streaming-engine"]
exclude = ["fuzz"]
resolver = "2"
[workspace.dependencies]
dolby_vision = { version = "3.3.2", default-features = false }
fluxa_core = { path = ".", default-features = false }
jni = { version = "0.21" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = "debuginfo"
panic = "unwind"
[package]
name = "fluxa_core"
version = "0.1.0"
@ -14,37 +33,27 @@ required-features = ["uniffi-cli"]
[features]
default = ["native"]
native = [
"full-api",
"dep:jni",
"dep:dolby_vision",
"uniffi-bindings",
]
uniffi-bindings = ["dep:uniffi"]
desktop = ["full-api"]
full-api = []
streaming-shared = []
uniffi-bindings = ["dep:uniffi", "full-api"]
uniffi-cli = ["uniffi-bindings", "uniffi/cli"]
wasm = ["dep:wasm-bindgen", "chrono/wasmbind"]
wasm = ["full-api", "dep:wasm-bindgen", "chrono/wasmbind"]
fuzzing = []
[dependencies]
base64 = "0.22"
chrono = { version = "0.4.45", features = ["serde"] }
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 }
dolby_vision = { workspace = true, optional = true }
jni = { workspace = true, optional = true }
regex = "1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde = { workspace = true }
serde_json = { workspace = true }
uniffi = { version = "0.31.1", optional = true }
wasm-bindgen = { version = "0.2", optional = true }
# std::time::Instant panics on wasm32-unknown-unknown (no OS clock without JS
# interop) — web-time is a drop-in replacement: std re-export on every other
# target, performance.now() under the hood on wasm.
web-time = "1"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = "debuginfo"
# Must stay "unwind" — bindings/jni.rs and ffi.rs::core_invoke catch_unwind at
# the FFI boundary so a panic in business logic becomes a safe error/null
# return instead of aborting the whole host process. "abort" would silently
# defeat that (and UniFFI's own built-in panic-to-exception handling too).
panic = "unwind"

View file

@ -4,7 +4,10 @@
| Feature | What it enables |
|---|---|
| `native` (default) | JNI bindings, Dolby Vision RPU, UniFFI Kotlin bindings |
| `native` (default) | Full Android/native surface: JNI bindings, Dolby Vision RPU, UniFFI Kotlin bindings |
| `full-api` | Complete domain/helper API surface used by JNI, `core_invoke`, UniFFI, WASM, and desktop |
| `desktop` | Named alias for the full desktop/Tauri-compatible API surface |
| `streaming-shared` | Minimal `FluxaCore` stream policy facade used by `fluxa-streaming-engine` |
| `uniffi-bindings` | UniFFI runtime support (pulled in by `native`) |
| `uniffi-cli` | Adds the `uniffi-bindgen` binary for generating Kotlin/Swift source |
| `wasm` | `wasm-bindgen` exports for webOS |
@ -22,6 +25,9 @@ cargo test --lib
# check the webOS/WASM path compiles
cargo check --no-default-features --features wasm
# check the narrow surface used by fluxa-streaming-engine
cargo check --no-default-features --features streaming-shared
# generate UniFFI Kotlin bindings
cargo run --bin uniffi-bindgen --features uniffi-cli -- generate \
--library target/debug/libfluxa_core.so \
@ -50,11 +56,11 @@ cargo build --release --target x86_64-linux-android
The Android project (`Fluxa`) picks up the resulting `.so` files from `target/<abi>/release/libfluxa_core.so`.
## WASM warnings are expected
## Partial API builds
Under `--no-default-features --features wasm`, roughly 240 "never used" warnings appear. This is correct: most of the crate is Android-only logic with no `core_invoke` route and no WASM wrapper. Don't add blanket `#[allow(dead_code)]` to silence them — they accurately show which functions are Android-only.
Non-native consumers intentionally compile partial API surfaces: desktop uses direct Rust/Tauri calls plus `core_invoke`, WASM exposes a small JS bridge, and `fluxa-streaming-engine` only needs stream policy helpers. These builds suppress dead-code noise from API functions that are only reachable through Android/JNI.
Under the default `native` build there are no warnings, because `bindings/jni.rs` uses almost every domain function.
The default `native` build keeps normal dead-code checking because it compiles the exhaustive Android JNI surface.
## Panic policy
@ -70,3 +76,5 @@ cargo build # native features (tokio, axum, librqbit, j
cargo build --bin torrent_serve # local torrent HTTP proxy
cargo build --bin companion_server # fluxa-web's local companion process
```
Its dependency on `fluxa_core` enables only `streaming-shared`, so streaming builds do not compile the full Android/desktop helper surface just to call stream playback and torrent runtime planning.

View file

@ -23,23 +23,13 @@ native = [
[dependencies]
axum = { version = "0.8", optional = true }
tower-http = { version = "0.6", features = ["cors"], optional = true }
dolby_vision = { git = "https://github.com/quietvoid/dovi_tool.git", package = "dolby_vision", default-features = false }
fluxa_core = { path = "..", default-features = false, optional = true }
jni = { version = "0.21", optional = true }
dolby_vision = { workspace = true }
fluxa_core = { workspace = true, features = ["streaming-shared"], optional = true }
jni = { workspace = true, optional = true }
librqbit = { version = "8.1.1", default-features = false, features = ["rust-tls", "disable-upload"], optional = true }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "form", "rustls"], optional = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "io-util", "sync", "process", "fs", "macros"], optional = true }
tokio-util = { version = "0.7", features = ["io"], optional = true }
url = { version = "2", optional = true }
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = "debuginfo"
# Must stay "unwind" — bindings/jni.rs catch_unwind at the FFI boundary so a
# panic in the streaming/rewrite logic becomes a safe null/false return
# instead of aborting the host app. "abort" would silently defeat that.
panic = "unwind"

View file

@ -1,5 +1,5 @@
use crate::chapters::parse_mkv_chapters_json;
use crate::dv_rewrite::{dv_auto_detect_was_iptpqc2, dv_get_stream_stats_json, dv_rewrite_segment_bytes, dv_rpu_self_test, start_dv_rewrite_local_stream_server};
use crate::dv_rewrite::{dv_auto_detect_was_iptpqc2, dv_get_current_l1_json, dv_get_stream_stats_json, dv_rewrite_segment_bytes, dv_rpu_self_test, start_dv_rewrite_local_stream_server};
use crate::local_stream::{start_local_stream_server, stop_local_stream_server};
use crate::torrent_engine;
use jni::objects::{JByteArray, JClass, JString};
@ -24,6 +24,9 @@ fn write_jstring(env: &mut JNIEnv<'_>, value: Option<String>) -> JStringReturn {
}
#[no_mangle]
/// # Safety
/// Called by the JVM with JNI-managed arguments. The function validates JNI
/// conversions, catches panics, and returns null on failure.
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_startLocalStreamServerNative(
mut env: JNIEnv<'_>,
_class: JObject<'_>,
@ -47,6 +50,9 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_
}
#[no_mangle]
/// # Safety
/// Called by the JVM with JNI-managed arguments. The function validates JNI
/// conversions, catches panics, and returns null on failure.
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_startDvRewriteLocalStreamServerNative(
mut env: JNIEnv<'_>,
_class: JObject<'_>,
@ -70,6 +76,9 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_
}
#[no_mangle]
/// # Safety
/// Called by the JVM with JNI-managed arguments. The function validates JNI
/// conversions, catches panics, and returns false on failure.
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_stopLocalStreamServerNative(
mut env: JNIEnv<'_>,
_class: JObject<'_>,
@ -85,21 +94,29 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_
}
#[no_mangle]
/// # Safety
/// Called by the JVM with JNI-managed arguments. The function validates JNI
/// conversions, catches panics, and returns null on failure.
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_startTorrentServerNative(
mut env: JNIEnv<'_>,
_class: JObject<'_>,
cache_dir: JString<'_>,
preferred_port: JInt,
access_token: JString<'_>,
) -> JStringReturn {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let output = read_jstring(&mut env, &cache_dir)
.and_then(|cache_dir| torrent_engine::start_torrent_server(&cache_dir, preferred_port));
let output = read_jstring(&mut env, &cache_dir).and_then(|cache_dir| {
let access_token = read_jstring(&mut env, &access_token).unwrap_or_default();
torrent_engine::start_torrent_server(&cache_dir, preferred_port, &access_token)
});
write_jstring(&mut env, output)
}))
.unwrap_or(ptr::null_mut())
}
#[no_mangle]
/// # Safety
/// Called by the JVM. The function catches panics and returns false on failure.
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_stopTorrentServerNative(
_env: JNIEnv<'_>,
_class: JObject<'_>,
@ -111,6 +128,8 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_
}
#[no_mangle]
/// # Safety
/// Called by the JVM. The function catches panics and returns false on failure.
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_dvRpuSelfTestNative(
_env: JNIEnv<'_>,
_class: JObject<'_>,
@ -122,6 +141,8 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_
}
#[no_mangle]
/// # Safety
/// Called by the JVM. The function catches panics and returns false on failure.
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_dvAutoDetectWasIptPqc2Native(
_env: JNIEnv<'_>,
_class: JObject<'_>,
@ -133,6 +154,9 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_
}
#[no_mangle]
/// # Safety
/// Called by the JVM with JNI-managed arguments. The function validates byte
/// array conversions, catches panics, and returns null on unrecoverable failure.
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_dvRewriteSegmentBytesNative(
env: JNIEnv<'_>,
_class: JObject<'_>,
@ -178,6 +202,8 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_
}
#[no_mangle]
/// # Safety
/// Called by the JVM. The function catches panics and returns null on failure.
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_dvGetStreamStatsJsonNative(
mut env: JNIEnv<'_>,
_class: JObject<'_>,
@ -189,6 +215,22 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_
}
#[no_mangle]
/// # Safety
/// Called by the JVM. The function catches panics and returns null on failure.
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_dvGetCurrentL1JsonNative(
mut env: JNIEnv<'_>,
_class: JObject<'_>,
) -> JStringReturn {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
write_jstring(&mut env, Some(dv_get_current_l1_json()))
}))
.unwrap_or(ptr::null_mut())
}
#[no_mangle]
/// # Safety
/// Called by the JVM with JNI-managed arguments. The function validates byte
/// array conversions, catches panics, and returns null on unrecoverable failure.
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_parseMkvChaptersNative(
mut env: JNIEnv<'_>,
_class: JObject<'_>,

View file

@ -4,8 +4,9 @@
/// Tauri commands in fluxa-desktop/src-tauri/src/lib.rs and oauth.rs, just
/// exposed over HTTP instead of IPC. Used by both the standalone
/// `companion_server` binary and the `fluxa-companion` tray app.
use axum::extract::State;
use axum::http::{HeaderValue, Method, StatusCode};
use axum::extract::{Request, State};
use axum::http::{header, HeaderMap, HeaderValue, Method, StatusCode};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
@ -34,6 +35,45 @@ async fn health() -> &'static str {
"ok"
}
fn companion_token() -> Option<String> {
std::env::var("FLUXA_COMPANION_TOKEN")
.ok()
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty())
}
fn token_authorized(
expected: &str,
authorization: Option<&HeaderValue>,
x_token: Option<&HeaderValue>,
) -> bool {
authorization
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.is_some_and(|token| token == expected)
|| x_token
.and_then(|value| value.to_str().ok())
.is_some_and(|token| token == expected)
}
async fn require_companion_token(request: Request, next: Next) -> Response {
if request.method() == Method::OPTIONS || request.uri().path() == "/health" {
return next.run(request).await;
}
let Some(expected) = companion_token() else {
return next.run(request).await;
};
let headers: &HeaderMap = request.headers();
if token_authorized(
&expected,
headers.get(header::AUTHORIZATION),
headers.get("x-fluxa-companion-token"),
) {
return next.run(request).await;
}
(StatusCode::UNAUTHORIZED, Json(json!({ "error": "unauthorized" }))).into_response()
}
async fn start_torrent(
State(state): State<Arc<AppState>>,
Json(body): Json<StartTorrentBody>,
@ -50,7 +90,12 @@ async fn start_torrent_inner(state: &AppState, body: StartTorrentBody) -> Result
Some(url) => url.clone(),
None => {
let cache_dir = std::env::temp_dir().join("fluxa-web-torrent-cache");
let server_json = crate::start_torrent_server(&cache_dir.to_string_lossy(), 0)
let access_token = std::env::var("FLUXA_COMPANION_TORRENT_TOKEN").unwrap_or_default();
let server_json = crate::start_torrent_server(
&cache_dir.to_string_lossy(),
0,
&access_token,
)
.ok_or_else(|| "failed to start torrent server".to_string())?;
let server: Value = serde_json::from_str(&server_json)
.map_err(|e| format!("invalid torrent server response: {e}"))?;
@ -157,8 +202,12 @@ fn cors_layer() -> CorsLayer {
CorsLayer::new()
.allow_origin(origins)
.allow_methods([Method::GET, Method::POST])
.allow_headers([axum::http::header::CONTENT_TYPE])
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers([
header::CONTENT_TYPE,
header::AUTHORIZATION,
axum::http::HeaderName::from_static("x-fluxa-companion-token"),
])
}
pub fn router() -> Router {
@ -170,6 +219,7 @@ pub fn router() -> Router {
.with_state(state)
.merge(crate::transcode::router())
.merge(crate::oauth_proxy::router())
.layer(middleware::from_fn(require_companion_token))
.layer(cors_layer())
}
@ -180,3 +230,37 @@ pub async fn serve(port: u16) -> std::io::Result<()> {
eprintln!("[companion-server] listening on http://127.0.0.1:{port}");
axum::serve(listener, router()).await
}
#[cfg(test)]
mod tests {
use super::token_authorized;
use axum::http::HeaderValue;
#[test]
fn companion_token_accepts_bearer_header() {
assert!(token_authorized(
"secret",
Some(&HeaderValue::from_static("Bearer secret")),
None,
));
}
#[test]
fn companion_token_accepts_custom_header() {
assert!(token_authorized(
"secret",
None,
Some(&HeaderValue::from_static("secret")),
));
}
#[test]
fn companion_token_rejects_missing_or_wrong_headers() {
assert!(!token_authorized("secret", None, None));
assert!(!token_authorized(
"secret",
Some(&HeaderValue::from_static("Bearer other")),
None,
));
}
}

View file

@ -1,4 +1,5 @@
use dolby_vision::rpu::dovi_rpu::DoviRpu;
use dolby_vision::rpu::extension_metadata::blocks::ExtMetadataBlock;
use serde::Deserialize;
// Startup self-test
@ -15,11 +16,36 @@ pub(crate) fn dv_rpu_self_test() -> bool {
// Set to true by stream_auto_detect when it strips a P5 CID≠1 (IPTPQc2) stream.
// Read by Kotlin in onVideoInputFormatChanged to activate the IPTPQc2 → SDR shader.
static DV_LAST_AUTO_DETECT_IPTPQC2: AtomicBool = AtomicBool::new(false);
static LAST_L1_VALID: AtomicBool = AtomicBool::new(false);
static LAST_L1_MIN_PQ: AtomicU32 = AtomicU32::new(0);
static LAST_L1_MAX_PQ: AtomicU32 = AtomicU32::new(2048);
static LAST_L1_AVG_PQ: AtomicU32 = AtomicU32::new(1024);
pub(crate) fn dv_auto_detect_was_iptpqc2() -> bool {
DV_LAST_AUTO_DETECT_IPTPQC2.load(Ordering::Relaxed)
}
pub(crate) fn dv_get_current_l1_json() -> String {
if !LAST_L1_VALID.load(Ordering::Relaxed) {
return "{\"available\":false}".to_string();
}
format!(
"{{\"available\":true,\"min_pq\":{},\"max_pq\":{},\"avg_pq\":{}}}",
LAST_L1_MIN_PQ.load(Ordering::Relaxed),
LAST_L1_MAX_PQ.load(Ordering::Relaxed),
LAST_L1_AVG_PQ.load(Ordering::Relaxed),
)
}
fn store_l1_from_rpu(rpu: &DoviRpu) {
let Some(dm) = &rpu.vdr_dm_data else { return };
let Some(ExtMetadataBlock::Level1(l1)) = dm.get_block(1) else { return };
LAST_L1_MIN_PQ.store(l1.min_pq as u32, Ordering::Relaxed);
LAST_L1_MAX_PQ.store(l1.max_pq as u32, Ordering::Relaxed);
LAST_L1_AVG_PQ.store(l1.avg_pq as u32, Ordering::Relaxed);
LAST_L1_VALID.store(true, Ordering::Relaxed);
}
// Synchronous byte-buffer segment rewriter
//
// Used by the Kotlin OkHttp interceptor to convert HLS segments (fMP4 .m4s or
@ -100,14 +126,14 @@ use serde_json::json;
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use crate::local_stream::{
build_proxy_client, local_stream_servers, parse_request, send_upstream_request,
write_simple_response, LocalStreamConfig, LocalStreamHandle, LOCAL_STREAM_ID,
build_proxy_client, local_stream_servers, next_local_stream_id, parse_request,
send_upstream_request, write_simple_response, LocalStreamConfig, LocalStreamHandle,
};
// Public config
@ -163,11 +189,9 @@ pub(crate) fn start_dv_rewrite_local_stream_server(
serde_json::from_str::<HashMap<String, String>>(headers_json).unwrap_or_default();
let dv_config = Arc::new(serde_json::from_str::<DvRewriteConfig>(dv_config_json).ok()?);
let id = LOCAL_STREAM_ID
.fetch_add(1, Ordering::Relaxed)
.to_string();
let id = next_local_stream_id();
let bind_port = preferred_port.clamp(0, u16::MAX as i32) as u16;
let listener = TcpListener::bind(("127.0.0.1", bind_port)).ok()?;
let listener = TcpListener::bind(("0.0.0.0", bind_port)).ok()?;
let port = listener.local_addr().ok()?.port();
listener.set_nonblocking(true).ok()?;
@ -178,6 +202,8 @@ pub(crate) fn start_dv_rewrite_local_stream_server(
target_url: target_url.to_string(),
headers,
client: Arc::new(build_proxy_client()),
active_connections: Arc::new(AtomicUsize::new(0)),
port,
};
let thread = thread::spawn(move || {
@ -209,7 +235,6 @@ pub(crate) fn start_dv_rewrite_local_stream_server(
.ok()
}
// Per-connection handler
fn handle_dv_stream(mut stream: TcpStream, config: LocalStreamConfig, dv: &DvRewriteConfig) {
let Some(request) = parse_request(&mut stream) else {
write_simple_response(&mut stream, "400 Bad Request");
@ -224,6 +249,11 @@ fn handle_dv_stream(mut stream: TcpStream, config: LocalStreamConfig, dv: &DvRew
return;
}
if dv.action == "hls_rpu_convert" {
handle_hls_rpu_convert(stream, config, dv, &request);
return;
}
let mut response =
match send_upstream_request(&config.client, &config, &request.method, &request.headers) {
Ok(r) => r,
@ -264,6 +294,289 @@ fn handle_dv_stream(mut stream: TcpStream, config: LocalStreamConfig, dv: &DvRew
}
}
fn handle_hls_rpu_convert(
mut downstream: TcpStream,
config: LocalStreamConfig,
dv: &DvRewriteConfig,
request: &crate::local_stream::ParsedLocalRequest,
) {
let seg_prefix = format!("/stream/{}/seg", config.id);
let stream_path = format!("/stream/{}", config.id);
if request.path.starts_with(&seg_prefix) {
let query = request.path[seg_prefix.len()..].trim_start_matches('?');
let seg_url = query
.split('&')
.find_map(|p| p.strip_prefix("u="))
.map(hls_percent_decode)
.unwrap_or_default();
if seg_url.is_empty() {
write_simple_response(&mut downstream, "400 Bad Request");
return;
}
let url_lower = seg_url.to_ascii_lowercase();
if url_lower.contains(".m3u8") {
serve_hls_manifest_rewritten(&mut downstream, &config, dv, &seg_url, request);
} else {
serve_hls_segment_rpu_convert(&mut downstream, &config, dv, &seg_url, request);
}
} else if request.path == stream_path
|| request.path.starts_with(&format!("{}?", stream_path))
{
serve_hls_manifest_rewritten(&mut downstream, &config, dv, &config.target_url.clone(), request);
} else {
write_simple_response(&mut downstream, "404 Not Found");
}
}
fn serve_hls_manifest_rewritten(
downstream: &mut TcpStream,
config: &LocalStreamConfig,
_dv: &DvRewriteConfig,
manifest_url: &str,
request: &crate::local_stream::ParsedLocalRequest,
) {
let mut upstream = match fetch_arbitrary_url(&config.client, manifest_url, &config.headers, &request.headers) {
Ok(r) => r,
Err(_) => {
write_simple_response(downstream, "502 Bad Gateway");
return;
}
};
let ct = upstream
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/vnd.apple.mpegurl")
.to_owned();
let body = match upstream.text() {
Ok(t) => t,
Err(_) => {
write_simple_response(downstream, "502 Bad Gateway");
return;
}
};
let proxy_seg_base = format!("http://127.0.0.1:{}/stream/{}/seg?u=", config.port, config.id);
let rewritten = rewrite_hls_manifest_for_dv(&body, manifest_url, &proxy_seg_base);
let bytes = rewritten.as_bytes();
let _ = write!(
downstream,
"HTTP/1.1 200 OK\r\nContent-Type: {ct}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
bytes.len()
);
if request.method != "HEAD" {
let _ = downstream.write_all(bytes);
}
}
fn serve_hls_segment_rpu_convert(
downstream: &mut TcpStream,
config: &LocalStreamConfig,
dv: &DvRewriteConfig,
seg_url: &str,
request: &crate::local_stream::ParsedLocalRequest,
) {
let mut response = match fetch_arbitrary_url(&config.client, seg_url, &config.headers, &request.headers) {
Ok(r) => r,
Err(_) => {
write_simple_response(downstream, "502 Bad Gateway");
return;
}
};
let status = response.status();
let _ = write!(downstream, "HTTP/1.1 {} {}\r\n", status.as_u16(), status.canonical_reason().unwrap_or("OK"));
for name in ["content-type", "content-range", "accept-ranges"] {
if let Some(v) = response.headers().get(name).and_then(|v| v.to_str().ok()) {
let _ = write!(downstream, "{name}: {v}\r\n");
}
}
let _ = write!(downstream, "Connection: close\r\n\r\n");
if request.method != "HEAD" {
stream_rpu_convert(&mut response, downstream, dv.rpu_mode, dv.zero_level5, dv.remove_hdr10plus);
}
}
fn fetch_arbitrary_url(
client: &reqwest::blocking::Client,
url: &str,
stream_headers: &HashMap<String, String>,
request_headers: &HashMap<String, String>,
) -> Result<reqwest::blocking::Response, reqwest::Error> {
let mut req = client.get(url);
for (k, v) in stream_headers {
req = req.header(k, v);
}
if let Some(range) = request_headers.get("range") {
req = req.header("Range", range);
}
req.send()
}
fn rewrite_hls_manifest_for_dv(manifest: &str, base_url: &str, proxy_seg_base: &str) -> String {
manifest
.lines()
.map(|line| {
if line.is_empty() {
return line.to_string();
}
if line.starts_with('#') {
let line = rewrite_hls_p7_codecs(line);
rewrite_hls_uri_attributes(&line, base_url, proxy_seg_base)
} else {
let abs = hls_resolve_url(base_url, line);
format!("{}{}", proxy_seg_base, hls_percent_encode(&abs))
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn rewrite_hls_p7_codecs(line: &str) -> String {
let lower = line.to_ascii_lowercase();
if !lower.contains("dvhe.07") && !lower.contains("dvh1.07") {
return line.to_string();
}
let mut result = String::with_capacity(line.len());
let bytes = line.as_bytes();
let lower_bytes = lower.as_bytes();
let mut i = 0;
while i < bytes.len() {
if lower_bytes[i..].starts_with(b"dvhe.07") {
result.push_str("dvhe.08");
i += 7;
} else if lower_bytes[i..].starts_with(b"dvh1.07") {
result.push_str("dvh1.08");
i += 7;
} else {
let ch = line[i..].chars().next().unwrap();
result.push(ch);
i += ch.len_utf8();
}
}
result
}
fn rewrite_hls_uri_attributes(line: &str, base_url: &str, proxy_seg_base: &str) -> String {
let lower = line.to_ascii_lowercase();
if !lower.contains("uri=\"") {
return line.to_string();
}
let mut result = String::with_capacity(line.len() + 128);
let mut rest = line;
loop {
let rest_lower = rest.to_ascii_lowercase();
let Some(pos) = rest_lower.find("uri=\"") else {
result.push_str(rest);
break;
};
result.push_str(&rest[..pos]);
let after = &rest[pos + 5..];
if let Some(end) = after.find('"') {
let inner = &after[..end];
let abs = hls_resolve_url(base_url, inner);
result.push_str("URI=\"");
result.push_str(&format!("{}{}", proxy_seg_base, hls_percent_encode(&abs)));
result.push('"');
rest = &after[end + 1..];
} else {
result.push_str(&rest[pos..]);
break;
}
}
result
}
fn hls_resolve_url(base_url: &str, relative: &str) -> String {
let rel_lower = relative.to_ascii_lowercase();
if rel_lower.starts_with("http://") || rel_lower.starts_with("https://") {
return relative.to_string();
}
if relative.starts_with('/') {
if let Some(scheme_end) = base_url.find("://") {
let rest = &base_url[scheme_end + 3..];
let authority_end = rest.find('/').unwrap_or(rest.len());
let origin = &base_url[..scheme_end + 3 + authority_end];
return format!("{}{}", origin, relative);
}
}
let base_dir = if let Some(pos) = base_url.rfind('/') {
if base_url[..pos].contains("://") || pos == 0 {
&base_url[..pos + 1]
} else {
&base_url[..pos + 1]
}
} else {
base_url
};
let combined = format!("{}{}", base_dir, relative);
hls_normalize_url_path(combined)
}
fn hls_normalize_url_path(url: String) -> String {
let path_start = if let Some(pos) = url.find("://") {
let rest = &url[pos + 3..];
pos + 3 + rest.find('/').unwrap_or(rest.len())
} else {
return url;
};
let (prefix, path) = url.split_at(path_start);
let mut parts: Vec<&str> = Vec::new();
for seg in path.split('/') {
match seg {
".." => { parts.pop(); }
"." | "" => {}
s => parts.push(s),
}
}
let trailing = if path.ends_with('/') { "/" } else { "" };
format!("{}/{}{}", prefix, parts.join("/"), trailing)
}
fn hls_percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
| b'-' | b'_' | b'.' | b'~' | b':' | b'/' | b'?' | b'#' | b'@' | b'!' | b'$'
| b'&' | b'\'' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'=' => {
out.push(byte as char);
}
b => {
out.push('%');
out.push(char::from_digit((b >> 4) as u32, 16).unwrap().to_ascii_uppercase());
out.push(char::from_digit((b & 0xf) as u32, 16).unwrap().to_ascii_uppercase());
}
}
}
out
}
fn hls_percent_decode(s: &str) -> String {
let mut out = Vec::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = char::from(bytes[i + 1]).to_digit(16);
let lo = char::from(bytes[i + 2]).to_digit(16);
if let (Some(h), Some(l)) = (hi, lo) {
out.push(((h << 4) | l) as u8);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
// DVCC strip (MKV / MP4 container)
//
// Searches the first 64 KiB of the stream for the DVCC or DVHE ISO-BMFF box
@ -1055,10 +1368,9 @@ fn emit_nal(nal_with_sc: &[u8], mode: &NalProcessMode, out: &mut Vec<u8>) -> (u3
fn convert_rpu_nal(nal: &[u8], mode: u8, zero_level5: bool) -> Option<Vec<u8>> {
let mut rpu = DoviRpu::parse_unspec62_nalu(nal).ok()?;
store_l1_from_rpu(&rpu);
rpu.convert_with_mode(mode).ok()?;
if zero_level5 {
// Zero all Level 5 active-area offsets — mirrors Kodi's SetDoviZeroLevel5.
// crop() calls set_active_area_offsets(0, 0, 0, 0) internally.
let _ = rpu.crop();
}
rpu.write_hevc_unspec62_nalu().ok()

View file

@ -13,16 +13,13 @@ fn platform_dir() -> &'static str {
fn bundled_path(name: &str) -> Option<PathBuf> {
let exe_dir = std::env::current_exe().ok()?.parent()?.to_path_buf();
let exe_name = if cfg!(windows) { format!("{name}.exe") } else { name.to_string() };
for candidate in [
[
exe_dir.join("resources/ffmpeg").join(platform_dir()).join(&exe_name),
// cargo run / cargo test layout: target/<profile>/ -> crate root/resources
exe_dir.join("../../resources/ffmpeg").join(platform_dir()).join(&exe_name),
] {
if candidate.is_file() {
return Some(candidate);
}
}
None
]
.into_iter()
.find(|candidate| candidate.is_file())
}
/// Resolves the ffmpeg/ffprobe binary to run: the bundled static build next to

View file

@ -1,13 +1,16 @@
use serde_json::json;
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::Duration;
const PROXY_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
const MAX_LOCAL_STREAM_CONNECTIONS: usize = 32;
pub(crate) fn build_proxy_client() -> reqwest::blocking::Client {
reqwest::blocking::Client::builder()
@ -25,6 +28,8 @@ pub(crate) struct LocalStreamConfig {
pub(crate) target_url: String,
pub(crate) headers: HashMap<String, String>,
pub(crate) client: Arc<reqwest::blocking::Client>,
pub(crate) active_connections: Arc<AtomicUsize>,
pub(crate) port: u16,
}
pub(crate) struct LocalStreamHandle {
@ -46,6 +51,40 @@ pub(crate) fn local_stream_servers() -> &'static Mutex<HashMap<String, LocalStre
LOCAL_STREAM_SERVERS.get_or_init(|| Mutex::new(HashMap::new()))
}
pub(crate) fn next_local_stream_id() -> String {
let counter = LOCAL_STREAM_ID.fetch_add(1, Ordering::Relaxed);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or_default();
let mut hasher = DefaultHasher::new();
counter.hash(&mut hasher);
now.hash(&mut hasher);
std::process::id().hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
struct ActiveConnectionGuard {
counter: Arc<AtomicUsize>,
}
impl ActiveConnectionGuard {
fn try_acquire(counter: Arc<AtomicUsize>) -> Option<Self> {
let previous = counter.fetch_add(1, Ordering::AcqRel);
if previous >= MAX_LOCAL_STREAM_CONNECTIONS {
counter.fetch_sub(1, Ordering::AcqRel);
return None;
}
Some(Self { counter })
}
}
impl Drop for ActiveConnectionGuard {
fn drop(&mut self) {
self.counter.fetch_sub(1, Ordering::AcqRel);
}
}
pub(crate) fn parse_request(stream: &mut TcpStream) -> Option<ParsedLocalRequest> {
let mut reader = BufReader::new(stream.try_clone().ok()?);
let mut request_line = String::new();
@ -125,6 +164,13 @@ pub(crate) fn send_upstream_request(
}
pub(crate) fn handle_local_stream(mut stream: TcpStream, config: LocalStreamConfig) {
let Some(_connection_guard) =
ActiveConnectionGuard::try_acquire(config.active_connections.clone())
else {
write_simple_response(&mut stream, "503 Service Unavailable");
return;
};
let Some(request) = parse_request(&mut stream) else {
write_simple_response(&mut stream, "400 Bad Request");
return;
@ -178,9 +224,9 @@ pub(crate) fn start_local_stream_server(
preferred_port: i32,
) -> Option<String> {
let headers = serde_json::from_str::<HashMap<String, String>>(headers_json).unwrap_or_default();
let id = LOCAL_STREAM_ID.fetch_add(1, Ordering::Relaxed).to_string();
let id = next_local_stream_id();
let bind_port = preferred_port.clamp(0, u16::MAX as i32) as u16;
let listener = TcpListener::bind(("127.0.0.1", bind_port)).ok()?;
let listener = TcpListener::bind(("0.0.0.0", bind_port)).ok()?;
let port = listener.local_addr().ok()?.port();
listener.set_nonblocking(true).ok()?;
@ -191,6 +237,8 @@ pub(crate) fn start_local_stream_server(
target_url: target_url.to_string(),
headers,
client: Arc::new(build_proxy_client()),
active_connections: Arc::new(AtomicUsize::new(0)),
port,
};
let thread = thread::spawn(move || {
while !thread_stop.load(Ordering::Relaxed) {
@ -235,3 +283,25 @@ pub(crate) fn stop_local_stream_server(id: &str) -> bool {
}
true
}
#[cfg(test)]
mod tests {
use super::{ActiveConnectionGuard, MAX_LOCAL_STREAM_CONNECTIONS};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
#[test]
fn active_connection_guard_caps_and_releases_slots() {
let counter = Arc::new(AtomicUsize::new(0));
let guards: Vec<_> = (0..MAX_LOCAL_STREAM_CONNECTIONS)
.map(|_| ActiveConnectionGuard::try_acquire(counter.clone()).unwrap())
.collect();
assert!(ActiveConnectionGuard::try_acquire(counter.clone()).is_none());
assert_eq!(counter.load(Ordering::Acquire), MAX_LOCAL_STREAM_CONNECTIONS);
drop(guards);
assert_eq!(counter.load(Ordering::Acquire), 0);
assert!(ActiveConnectionGuard::try_acquire(counter.clone()).is_some());
}
}

View file

@ -1,5 +1,5 @@
use axum::body::Body;
use axum::extract::{Query, State};
use axum::extract::{connect_info::ConnectInfo, Query, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
@ -13,11 +13,12 @@ use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use std::io::SeekFrom;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::Duration;
use tokio::io::AsyncSeekExt;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio::sync::Mutex as AsyncMutex;
@ -48,6 +49,7 @@ struct StreamQuery {
title: Option<String>,
index: Option<usize>,
stat: Option<String>,
access_token: Option<String>,
}
#[derive(Clone)]
@ -56,6 +58,7 @@ struct EngineState {
output_dir: PathBuf,
preload_size: Arc<Mutex<u64>>,
known_links: Arc<Mutex<HashMap<String, usize>>>,
access_token: Arc<String>,
// Serializes the check-then-add sequence in ensure_torrent so two
// near-simultaneous requests for the same new link (e.g. a stat poll
// racing the stream GET) can't both call api_add_torrent for it.
@ -73,7 +76,17 @@ 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> {
fn debug_log(message: impl AsRef<str>) {
if std::env::var_os("FLUXA_TORRENT_DEBUG").is_some() {
eprintln!("{}", message.as_ref());
}
}
pub fn start_torrent_server(
cache_dir: &str,
preferred_port: i32,
access_token: &str,
) -> Option<String> {
// Stop any existing server first. The mutex is the single source of truth
// for whether the server is running — no separate AtomicBool needed.
{
@ -97,6 +110,7 @@ pub fn start_torrent_server(cache_dir: &str, preferred_port: i32) -> Option<Stri
let (stop_tx, stop_rx) = oneshot::channel::<()>();
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
let thread_cache_dir = cache_dir.clone();
let thread_access_token = access_token.trim().to_string();
let thread = thread::spawn(move || {
let worker_threads = std::thread::available_parallelism()
@ -123,24 +137,26 @@ pub fn start_torrent_server(cache_dir: &str, preferred_port: i32) -> Option<Stri
}
};
let mut options = SessionOptions::default();
options.disable_dht_persistence = true;
options.defer_writes_up_to = Some(64);
options.listen_port_range = Some(49152..65535);
options.disable_upload = true;
options.concurrent_init_limit = Some(2);
options.trackers = [
"udp://tracker.opentrackr.org:1337/announce",
"udp://open.demonii.com:1337/announce",
"udp://tracker.openbittorrent.com:80/announce",
"udp://exodus.desync.com:6969/announce",
"udp://open.stealth.si:80/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://tracker.tiny-vps.com:6969/announce",
]
.iter()
.filter_map(|s| s.parse().ok())
.collect();
let options = SessionOptions {
disable_dht_persistence: true,
defer_writes_up_to: Some(64),
listen_port_range: Some(49152..65535),
disable_upload: true,
concurrent_init_limit: Some(2),
trackers: [
"udp://tracker.opentrackr.org:1337/announce",
"udp://open.demonii.com:1337/announce",
"udp://tracker.openbittorrent.com:80/announce",
"udp://exodus.desync.com:6969/announce",
"udp://open.stealth.si:80/announce",
"udp://tracker.torrent.eu.org:451/announce",
"udp://tracker.tiny-vps.com:6969/announce",
]
.iter()
.filter_map(|s| s.parse().ok())
.collect(),
..Default::default()
};
let session = match Session::new_with_opts(thread_cache_dir.clone(), options).await {
Ok(session) => session,
@ -155,6 +171,7 @@ pub fn start_torrent_server(cache_dir: &str, preferred_port: i32) -> Option<Stri
output_dir: thread_cache_dir,
preload_size: Arc::new(Mutex::new(10 * 1024 * 1024)),
known_links: Arc::new(Mutex::new(HashMap::new())),
access_token: Arc::new(thread_access_token),
add_lock: Arc::new(AsyncMutex::new(())),
};
let app = Router::new()
@ -164,7 +181,11 @@ pub fn start_torrent_server(cache_dir: &str, preferred_port: i32) -> Option<Stri
.route("/stream/fname", get(stream_fname))
.with_state(state);
let server = axum::serve(listener, app).with_graceful_shutdown(async move {
let server = axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async move {
let _ = stop_rx.await;
});
let _ = ready_tx.send(Ok(()));
@ -212,17 +233,28 @@ async fn root() -> impl IntoResponse {
async fn update_settings(
State(state): State<EngineState>,
ConnectInfo(remote_addr): ConnectInfo<SocketAddr>,
Json(settings): Json<TorrSettings>,
) -> impl IntoResponse {
if !request_authorized(&state, remote_addr, None) {
return error_response(StatusCode::UNAUTHORIZED, "unauthorized");
}
if let Some(preload_mb) = settings.preload_size {
if let Ok(mut preload_size) = state.preload_size.lock() {
*preload_size = preload_mb.saturating_mul(1024 * 1024);
}
}
(StatusCode::OK, Json(json!({})))
(StatusCode::OK, Json(json!({}))).into_response()
}
async fn torrents(State(state): State<EngineState>, Json(request): Json<TorrRequest>) -> Response {
async fn torrents(
State(state): State<EngineState>,
ConnectInfo(remote_addr): ConnectInfo<SocketAddr>,
Json(request): Json<TorrRequest>,
) -> Response {
if !request_authorized(&state, remote_addr, None) {
return error_response(StatusCode::UNAUTHORIZED, "unauthorized");
}
let _ = request.save_to_db;
let action = request.action.to_ascii_lowercase();
match action.as_str() {
@ -278,9 +310,13 @@ async fn stream_fname(
State(state): State<EngineState>,
Query(query): Query<StreamQuery>,
headers: HeaderMap,
ConnectInfo(remote_addr): ConnectInfo<SocketAddr>,
) -> Response {
if !request_authorized(&state, remote_addr, query.access_token.as_deref()) {
return error_response(StatusCode::UNAUTHORIZED, "unauthorized");
}
let range_header = headers.get("Range").and_then(|v| v.to_str().ok()).unwrap_or("none");
eprintln!("[TorrServer] stream_fname link={} stat={} range={range_header}", &query.link[..query.link.len().min(60)], query.stat.is_some());
debug_log(format!("[TorrServer] stream_fname link={} stat={} range={range_header}", &query.link[..query.link.len().min(60)], query.stat.is_some()));
// Stat requests return immediately — no retry loop (used by Kotlin status polling)
if query.stat.is_some() {
@ -300,14 +336,14 @@ async fn stream_fname(
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}");
debug_log(format!("[TorrServer] ensure_torrent failed: {error}"));
return error_response(StatusCode::SERVICE_UNAVAILABLE, error);
}
};
let file_id = query
.index
.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));
debug_log(format!("[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;
// Wait for rqbit to leave Initializing state before attempting to stream.
@ -321,7 +357,7 @@ async fn stream_fname(
)
.await
{
eprintln!("[TorrServer] wait_until_initialized timed out torrent={id}: {e}");
debug_log(format!("[TorrServer] wait_until_initialized timed out torrent={id}: {e}"));
return error_response(StatusCode::SERVICE_UNAVAILABLE, "torrent init timed out");
}
}
@ -337,28 +373,29 @@ async fn stream_fname(
}
}
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());
match parse_range(headers.get("Range"), total_len) {
Ok(Some((start, end))) => {
if let Err(error) = stream.seek(SeekFrom::Start(start)).await {
debug_log(format!("[TorrServer] seek failed torrent={id} file={file_id} start={start} len={total_len}: {error}"));
return error_response(StatusCode::INTERNAL_SERVER_ERROR, "failed to seek stream");
}
status = StatusCode::PARTIAL_CONTENT;
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}"));
let body = Body::from_stream(ReaderStream::with_capacity(stream.take(length), 65536));
(status, output_headers, body).into_response()
}
} else {
insert_header(&mut output_headers, "Content-Length", total_len.to_string());
Ok(None) => {
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(()) => range_not_satisfiable_response(total_len),
}
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:#}");
debug_log(format!("[TorrServer] api_stream failed torrent={id} file={file_id}: {e:#}"));
error_response(StatusCode::NOT_FOUND, format!("{e:#}"))
}
}
@ -395,14 +432,16 @@ async fn ensure_torrent(
return Ok((id, details));
}
let mut options = AddTorrentOptions::default();
options.overwrite = true;
options.output_folder = Some(state.output_dir.to_string_lossy().into_owned());
options.peer_opts = Some(PeerConnectionOptions {
connect_timeout: Some(Duration::from_secs(5)),
read_write_timeout: Some(Duration::from_secs(20)),
let mut options = AddTorrentOptions {
overwrite: true,
output_folder: Some(state.output_dir.to_string_lossy().into_owned()),
peer_opts: Some(PeerConnectionOptions {
connect_timeout: Some(Duration::from_secs(5)),
read_write_timeout: Some(Duration::from_secs(20)),
..Default::default()
}),
..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 {
@ -495,18 +534,38 @@ async fn status_response(
}))
}
fn parse_range(value: Option<&HeaderValue>, length: u64) -> Option<(u64, Option<u64>)> {
let raw = value?.to_str().ok()?.strip_prefix("bytes=")?;
let (start, end) = raw.split_once('-')?;
let start = start.parse::<u64>().ok()?;
if start >= length {
return None;
fn parse_range(value: Option<&HeaderValue>, length: u64) -> Result<Option<(u64, u64)>, ()> {
let Some(value) = value else {
return Ok(None);
};
let raw = value.to_str().map_err(|_| ())?;
let spec = raw.strip_prefix("bytes=").ok_or(())?;
if spec.contains(',') || length == 0 {
return Err(());
}
let end = end
.parse::<u64>()
.ok()
.map(|end| end.min(length.saturating_sub(1)));
Some((start, end))
let (start, end) = spec.split_once('-').ok_or(())?;
if start.is_empty() {
let suffix_len = end.parse::<u64>().map_err(|_| ())?;
if suffix_len == 0 {
return Err(());
}
let start = length.saturating_sub(suffix_len);
return Ok(Some((start, length.saturating_sub(1))));
}
let start = start.parse::<u64>().map_err(|_| ())?;
if start >= length {
return Err(());
}
let end = if end.is_empty() {
length.saturating_sub(1)
} else {
let end = end.parse::<u64>().map_err(|_| ())?;
if end < start {
return Err(());
}
end.min(length.saturating_sub(1))
};
Ok(Some((start, end)))
}
fn insert_header(headers: &mut HeaderMap, key: &'static str, value: String) {
@ -550,3 +609,68 @@ fn remember_link(state: &EngineState, link: &str, id: usize) {
fn error_response(message_status: StatusCode, message: impl Into<String>) -> Response {
(message_status, Json(json!({ "error": message.into() }))).into_response()
}
fn request_authorized(
state: &EngineState,
remote_addr: SocketAddr,
access_token: Option<&str>,
) -> bool {
remote_addr.ip().is_loopback()
|| (!state.access_token.is_empty()
&& access_token.is_some_and(|token| token == state.access_token.as_str()))
}
fn range_not_satisfiable_response(length: u64) -> Response {
let mut headers = HeaderMap::new();
insert_header(&mut headers, "Content-Range", format!("bytes */{length}"));
(
StatusCode::RANGE_NOT_SATISFIABLE,
headers,
Json(json!({ "error": "range not satisfiable" })),
)
.into_response()
}
#[cfg(test)]
mod tests {
use super::parse_range;
use axum::http::HeaderValue;
fn range(value: &str, length: u64) -> Result<Option<(u64, u64)>, ()> {
parse_range(Some(&HeaderValue::from_str(value).unwrap()), length)
}
#[test]
fn parses_open_ended_range() {
assert_eq!(range("bytes=100-", 1000), Ok(Some((100, 999))));
}
#[test]
fn parses_bounded_range() {
assert_eq!(range("bytes=100-199", 1000), Ok(Some((100, 199))));
}
#[test]
fn clamps_bounded_range_to_file_end() {
assert_eq!(range("bytes=900-2000", 1000), Ok(Some((900, 999))));
}
#[test]
fn parses_suffix_range() {
assert_eq!(range("bytes=-200", 1000), Ok(Some((800, 999))));
}
#[test]
fn rejects_unsatisfiable_and_malformed_ranges() {
assert_eq!(range("bytes=1000-", 1000), Err(()));
assert_eq!(range("bytes=200-100", 1000), Err(()));
assert_eq!(range("items=0-1", 1000), Err(()));
assert_eq!(range("bytes=0-1,2-3", 1000), Err(()));
assert_eq!(range("bytes=-0", 1000), Err(()));
}
#[test]
fn no_range_header_means_full_response() {
assert_eq!(parse_range(None, 1000), Ok(None));
}
}

View file

@ -127,10 +127,9 @@ pub(crate) fn base_url(raw: &str) -> String {
format!("{without_manifest}/")
};
let lower = base.to_ascii_lowercase();
if lower.contains("localhost") || lower.contains("127.0.0.1") {
if lower.starts_with("https://") {
base = format!("http://{}", &base[8..]);
}
if (lower.contains("localhost") || lower.contains("127.0.0.1")) && lower.starts_with("https://")
{
base = format!("http://{}", &base[8..]);
}
base
}
@ -477,11 +476,7 @@ pub(crate) fn parse_catalogs(json: &Value) -> Vec<Value> {
// pub rather than pub(crate): re-exported under fuzz_targets for the `fuzz/`
// crate (see lib.rs). Not part of the supported public API otherwise.
pub fn parse_manifest(
body: &str,
transport_url: &str,
unknown_name: &str,
) -> Option<String> {
pub fn parse_manifest(body: &str, transport_url: &str, unknown_name: &str) -> Option<String> {
let json: Value = serde_json::from_str(body).ok()?;
let behavior_hints = json.get("behaviorHints");
let logo = first_text(
@ -978,7 +973,13 @@ mod tests {
#[test]
fn build_resource_url_appends_extra_path_segment_and_omits_blank_values() {
assert_eq!(
build_resource_url("https://addon.example/manifest.json", "stream", "movie", "tt123", None),
build_resource_url(
"https://addon.example/manifest.json",
"stream",
"movie",
"tt123",
None
),
"https://addon.example/stream/movie/tt123.json"
);
assert_eq!(
@ -1011,9 +1012,19 @@ mod tests {
"types": ["movie"],
"idPrefixes": ["tt"],
});
assert!(supports_resource(&manifest.to_string(), "streams", Some("movie"), Some("tt123")));
assert!(supports_resource(
&manifest.to_string(),
"streams",
Some("movie"),
Some("tt123")
));
// Wrong content type for this manifest's declared types.
assert!(!supports_resource(&manifest.to_string(), "stream", Some("series"), Some("tt123")));
assert!(!supports_resource(
&manifest.to_string(),
"stream",
Some("series"),
Some("tt123")
));
let catalog_manifest = json!({
"resources": [{ "name": "catalog", "types": ["movie"], "idPrefixes": ["tt"] }],

View file

@ -1,7 +1,7 @@
use crate::addon_protocol;
use serde_json::{json, Value};
fn resource_payload<'a>(resource: &str, root: &'a Value) -> Option<Value> {
fn resource_payload(resource: &str, root: &Value) -> Option<Value> {
match resource {
"stream" | "streams" => root.get("streams").cloned(),
"catalog" | "metas" => root.get("metas").cloned(),

View file

@ -352,7 +352,8 @@ pub(crate) fn addon_store_search_policy_json(request_json: &str) -> Option<Strin
fn manifest_url_regex() -> &'static Regex {
static REGEX: OnceLock<Regex> = OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r#"https?://[^"'\\ ]+manifest\.json[^"'\\ ]*"#).expect("valid manifest url regex")
Regex::new(r#"https?://[^"'\\ ]+manifest\.json[^"'\\ ]*"#)
.expect("valid manifest url regex")
})
}

View file

@ -264,7 +264,9 @@ fn store() -> &'static Mutex<HashMap<u64, AppCoreState>> {
// See headless_engine::lock_engines — recovering from poison keeps this store
// usable after a single caught panic instead of going dark for every handle.
fn lock_store() -> std::sync::MutexGuard<'static, HashMap<u64, AppCoreState>> {
store().lock().unwrap_or_else(|poisoned| poisoned.into_inner())
store()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub fn create_app_core_state(initial_json: &str) -> u64 {

File diff suppressed because it is too large Load diff

View file

@ -19,12 +19,17 @@ pub fn core_invoke(method: String, args_json: String) -> String {
#[uniffi::export]
pub fn create_headless_engine_json(initial_json: String) -> i64 {
guard(0, || headless_engine::create_headless_engine(&initial_json) as i64)
guard(0, || {
headless_engine::create_headless_engine(&initial_json) as i64
})
}
#[uniffi::export]
pub fn destroy_headless_engine_json(handle: i64) -> bool {
handle > 0 && guard(false, || headless_engine::destroy_headless_engine(handle as u64))
handle > 0
&& guard(false, || {
headless_engine::destroy_headless_engine(handle as u64)
})
}
#[uniffi::export]
@ -43,7 +48,8 @@ pub fn headless_engine_dispatch_json(handle: i64, action_json: String) -> String
return String::new();
}
guard(String::new(), || {
headless_engine::headless_engine_dispatch_json(handle as u64, &action_json).unwrap_or_default()
headless_engine::headless_engine_dispatch_json(handle as u64, &action_json)
.unwrap_or_default()
})
}
@ -60,7 +66,9 @@ pub fn headless_engine_complete_effect_json(handle: i64, result_json: String) ->
#[uniffi::export]
pub fn core_capabilities_json(portable: bool) -> String {
guard(String::new(), || core_contract::core_capabilities_json(portable))
guard(String::new(), || {
core_contract::core_capabilities_json(portable)
})
}
#[uniffi::export]

View file

@ -146,7 +146,7 @@ pub(crate) fn calendar_season_candidates_json(request_json: &str) -> Option<Stri
};
let mut result: Vec<i32> = focused
.into_iter()
.chain(full.into_iter())
.chain(full)
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
@ -217,14 +217,12 @@ pub(crate) fn calendar_notification_content_json(request_json: &str) -> Option<S
};
let body_text = match (item.season_number, item.episode_number) {
(Some(s), Some(e)) => format!("{}:season:{}:episode:{}", item.title, s, e),
_ => {
[Some(item.title.as_str()), item.subtitle.as_deref()]
.into_iter()
.flatten()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" - ")
}
_ => [Some(item.title.as_str()), item.subtitle.as_deref()]
.into_iter()
.flatten()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" - "),
};
items_out.push(json!({
"key": key,
@ -260,23 +258,43 @@ pub(crate) fn calendar_items_from_meta_json(meta_json: &str, month_prefix: &str)
let meta: Value = serde_json::from_str(meta_json).ok()?;
let meta_id = meta.get("id").and_then(Value::as_str).unwrap_or("");
let meta_name = meta.get("name").and_then(Value::as_str).unwrap_or("");
let meta_poster = meta.get("poster").and_then(Value::as_str)
let meta_poster = meta
.get("poster")
.and_then(Value::as_str)
.or_else(|| meta.get("background").and_then(Value::as_str));
let videos = meta.get("videos").and_then(Value::as_array)?;
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 = match released.get(..10) { Some(d) => d, None => continue };
if !month_prefix.is_empty() && !date_iso.starts_with(month_prefix) { 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);
let episode = video
.get("episode")
.or_else(|| video.get("number"))
.and_then(Value::as_i64);
let episode_code = match (season, episode) {
(Some(s), Some(e)) => Some(format!("S{s}:E{e}")),
_ => None,
};
let video_name = video.get("name").or_else(|| video.get("title")).and_then(Value::as_str);
let subtitle = [episode_code.as_deref(), video_name].into_iter().flatten().collect::<Vec<_>>().join(" ");
let poster = video.get("thumbnail").and_then(Value::as_str).or(meta_poster);
let video_name = video
.get("name")
.or_else(|| video.get("title"))
.and_then(Value::as_str);
let subtitle = [episode_code.as_deref(), video_name]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" ");
let poster = video
.get("thumbnail")
.and_then(Value::as_str)
.or(meta_poster);
let video_id = video.get("id").and_then(Value::as_str).unwrap_or("");
let key = format!("{meta_id}:{video_id}:{date_iso}");
items.push(json!({
@ -313,9 +331,16 @@ pub(crate) fn next_unaired_episode_json(videos_json: &str, now_ms: i64) -> Optio
}
pub(crate) fn calendar_item_matches_month_json(item_json: &str, month_prefix: &str) -> bool {
if month_prefix.is_empty() { return true; }
serde_json::from_str::<Value>(item_json).ok()
.and_then(|v| v.get("dateIso").and_then(Value::as_str).map(|d| d.starts_with(month_prefix)))
if month_prefix.is_empty() {
return true;
}
serde_json::from_str::<Value>(item_json)
.ok()
.and_then(|v| {
v.get("dateIso")
.and_then(Value::as_str)
.map(|d| d.starts_with(month_prefix))
})
.unwrap_or(false)
}
@ -347,10 +372,8 @@ mod tests {
#[test]
fn season_candidates_covers_watched_next_and_last_season() {
let result: Value = serde_json::from_str(
&calendar_season_candidates_json(
r#"{"seasonsCount":5,"lastVideoId":"tt1:2:3"}"#,
)
.unwrap(),
&calendar_season_candidates_json(r#"{"seasonsCount":5,"lastVideoId":"tt1:2:3"}"#)
.unwrap(),
)
.unwrap();
let seasons: Vec<i64> = result
@ -396,31 +419,35 @@ mod tests {
"notificationsEnabled": true,
"alertNewEpisodes": true
});
let result: Value =
serde_json::from_str(&calendar_notification_content_json(&request.to_string()).unwrap())
.unwrap();
let result: Value = serde_json::from_str(
&calendar_notification_content_json(&request.to_string()).unwrap(),
)
.unwrap();
assert_eq!(result["items"].as_array().unwrap().len(), 0);
}
#[test]
fn next_unaired_episode_picks_earliest_future_date() {
let now_ms = chrono::DateTime::parse_from_rfc3339("2026-06-16T00:00:00Z").unwrap().timestamp_millis();
let now_ms = chrono::DateTime::parse_from_rfc3339("2026-06-16T00:00:00Z")
.unwrap()
.timestamp_millis();
let videos = json!([
{"id": "v1", "released": "2026-06-01T00:00:00Z"},
{"id": "v2", "released": "2026-07-10T00:00:00Z"},
{"id": "v3", "released": "2026-06-20T00:00:00Z"},
{"id": "v4"}
]);
let result: Value = serde_json::from_str(
&next_unaired_episode_json(&videos.to_string(), now_ms).unwrap(),
)
.unwrap();
let result: Value =
serde_json::from_str(&next_unaired_episode_json(&videos.to_string(), now_ms).unwrap())
.unwrap();
assert_eq!(result["id"], "v3");
}
#[test]
fn next_unaired_episode_returns_none_when_nothing_upcoming() {
let now_ms = chrono::DateTime::parse_from_rfc3339("2026-06-16T00:00:00Z").unwrap().timestamp_millis();
let now_ms = chrono::DateTime::parse_from_rfc3339("2026-06-16T00:00:00Z")
.unwrap()
.timestamp_millis();
let videos = json!([
{"id": "v1", "released": "2026-06-01T00:00:00Z"},
{"id": "v2"}

View file

@ -5,7 +5,9 @@ pub(crate) const RENDERING_CONTROL_URN: &str = "urn:schemas-upnp-org:service:Ren
pub(crate) fn validate_stream_url(url: &str) -> bool {
let trimmed = url.trim();
let Some(scheme_end) = trimmed.find("://") else { return false };
let Some(scheme_end) = trimmed.find("://") else {
return false;
};
let scheme = trimmed[..scheme_end].to_ascii_lowercase();
if scheme != "http" && scheme != "https" {
return false;
@ -14,7 +16,10 @@ pub(crate) fn validate_stream_url(url: &str) -> bool {
}
pub(crate) fn xml_escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn extract_tag(xml: &str, tag: &str) -> Option<String> {
@ -70,7 +75,12 @@ pub(crate) fn soap_action_body(urn: &str, action: &str, args: &str) -> String {
fn format_didl_metadata(title: &str, subtitle_url: Option<&str>) -> String {
let subtitle_res = subtitle_url
.map(|url| format!("<res protocolInfo=\"http-get:*:text/srt:*\">{}</res>", xml_escape(url)))
.map(|url| {
format!(
"<res protocolInfo=\"http-get:*:text/srt:*\">{}</res>",
xml_escape(url)
)
})
.unwrap_or_default();
let didl = format!(
"<DIDL-Lite xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\">\
@ -80,7 +90,11 @@ fn format_didl_metadata(title: &str, subtitle_url: Option<&str>) -> String {
xml_escape(&didl)
}
pub(crate) fn dlna_set_av_transport_args(media_url: &str, title: &str, subtitle_url: Option<&str>) -> Option<String> {
pub(crate) fn dlna_set_av_transport_args(
media_url: &str,
title: &str,
subtitle_url: Option<&str>,
) -> Option<String> {
if !validate_stream_url(media_url) {
return None;
}
@ -93,11 +107,19 @@ pub(crate) fn dlna_set_av_transport_args(media_url: &str, title: &str, subtitle_
pub(crate) fn format_hms(total_secs: f64) -> String {
let total = total_secs.max(0.0) as u64;
format!("{:02}:{:02}:{:02}", total / 3600, (total % 3600) / 60, total % 60)
format!(
"{:02}:{:02}:{:02}",
total / 3600,
(total % 3600) / 60,
total % 60
)
}
pub(crate) fn dlna_seek_args(position_secs: f64) -> String {
format!("<InstanceID>0</InstanceID><Unit>ABS_TIME</Unit><Target>{}</Target>", format_hms(position_secs))
format!(
"<InstanceID>0</InstanceID><Unit>ABS_TIME</Unit><Target>{}</Target>",
format_hms(position_secs)
)
}
pub(crate) fn dlna_set_volume_args(level: f64) -> String {
@ -113,7 +135,11 @@ pub(crate) fn resolve_loopback_url(stream_url: &str, lan_ip: &str) -> String {
}
pub(crate) fn guess_cast_content_type(media_url: &str) -> &'static str {
let path = media_url.split(['?', '#']).next().unwrap_or(media_url).to_ascii_lowercase();
let path = media_url
.split(['?', '#'])
.next()
.unwrap_or(media_url)
.to_ascii_lowercase();
if path.ends_with(".m3u8") {
"application/x-mpegurl"
} else if path.ends_with(".mkv") {
@ -147,7 +173,12 @@ fn write_string_field(buf: &mut Vec<u8>, field: u32, value: &str) {
buf.extend_from_slice(value.as_bytes());
}
pub(crate) fn encode_cast_message(source_id: &str, destination_id: &str, namespace: &str, payload_utf8: &str) -> Vec<u8> {
pub(crate) fn encode_cast_message(
source_id: &str,
destination_id: &str,
namespace: &str,
payload_utf8: &str,
) -> Vec<u8> {
let mut buf = Vec::new();
write_tag(&mut buf, 1, 0);
write_varint(&mut buf, 0);
@ -206,14 +237,19 @@ pub(crate) fn decode_cast_message(buf: &[u8]) -> Option<DecodedCastMessage> {
_ => return None,
}
}
Some(DecodedCastMessage { namespace, payload_utf8 })
Some(DecodedCastMessage {
namespace,
payload_utf8,
})
}
fn roku_url_encode(value: &str) -> String {
let mut out = String::new();
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(byte as char),
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
@ -224,7 +260,11 @@ pub(crate) fn roku_device_name(xml: &str) -> Option<String> {
extract_tag(xml, "friendly-device-name")
}
pub(crate) fn roku_launch_url(host: &str, media_url: &str, subtitle_url: Option<&str>) -> Option<String> {
pub(crate) fn roku_launch_url(
host: &str,
media_url: &str,
subtitle_url: Option<&str>,
) -> Option<String> {
if !validate_stream_url(media_url) {
return None;
}
@ -234,7 +274,10 @@ pub(crate) fn roku_launch_url(host: &str, media_url: &str, subtitle_url: Option<
}
}
const ROKU_MEDIA_PLAYER_APP_ID: &str = "2213";
let mut url = format!("http://{host}:8060/launch/{ROKU_MEDIA_PLAYER_APP_ID}?t=v&u={}", roku_url_encode(media_url));
let mut url = format!(
"http://{host}:8060/launch/{ROKU_MEDIA_PLAYER_APP_ID}?t=v&u={}",
roku_url_encode(media_url)
);
if let Some(sub) = subtitle_url {
url.push_str(&format!("&k={}", roku_url_encode(sub)));
}
@ -253,7 +296,9 @@ pub(crate) fn airplay_play_body(media_url: &str) -> Option<String> {
if !validate_stream_url(media_url) {
return None;
}
Some(format!("Content-Location: {media_url}\nStart-Position: 0\n"))
Some(format!(
"Content-Location: {media_url}\nStart-Position: 0\n"
))
}
#[cfg(test)]
@ -275,7 +320,8 @@ mod tests {
#[test]
fn didl_title_with_markup_does_not_break_out_of_the_item_tag() {
let args = dlna_set_av_transport_args("http://192.168.1.5/a.mkv", "</item><script>", None).unwrap();
let args = dlna_set_av_transport_args("http://192.168.1.5/a.mkv", "</item><script>", None)
.unwrap();
assert!(!args.contains("<script>"));
}

View file

@ -168,7 +168,9 @@ pub(crate) fn playback_intro_lookup_content_id(id: &str) -> String {
if let Some(imdb) = imdb_id(id) {
return imdb;
}
base_content_id(id).trim_start_matches(TMDB_ID_PREFIX).to_string()
base_content_id(id)
.trim_start_matches(TMDB_ID_PREFIX)
.to_string()
}
pub(crate) fn playback_stream_request_ids_json(
@ -603,10 +605,8 @@ pub(crate) fn set_metadata_feed_group_enabled_json(
let group = parse_string_list(group_keys_json);
let mut output = Vec::<String>::new();
for item in current {
if enabled || !group.contains(&item) {
if !output.contains(&item) {
output.push(item);
}
if (enabled || !group.contains(&item)) && !output.contains(&item) {
output.push(item);
}
}
if enabled {
@ -1242,7 +1242,8 @@ pub(crate) fn parse_video_id_json(id: &str) -> String {
} else {
map.insert("isEpisode".into(), false.into());
}
serde_json::to_string(&serde_json::Value::Object(map)).unwrap_or_else(|_| r#"{"isEpisode":false}"#.to_string())
serde_json::to_string(&serde_json::Value::Object(map))
.unwrap_or_else(|_| r#"{"isEpisode":false}"#.to_string())
}
pub(crate) fn build_trakt_ids_json(video_id: &str) -> Option<String> {
@ -1384,7 +1385,11 @@ mod tests {
#[test]
fn contains_spaced_episode_matches_word_form_and_skips_wrong_season_occurrence() {
assert!(contains_spaced_episode("Show Name Season 1 Episode 2 1080p", 1, 2));
assert!(contains_spaced_episode(
"Show Name Season 1 Episode 2 1080p",
1,
2
));
// First "Season 2" occurrence doesn't match the target season (1), so the
// scan must continue past it to the second "Season ... Episode ..." pair.
assert!(contains_spaced_episode(
@ -1397,7 +1402,11 @@ mod tests {
assert!(!contains_spaced_episode("Season 1 Episode 10", 1, 1));
assert!(contains_spaced_episode("Season 1 Episode 10", 1, 10));
// A season number that matches but with no "Episode" anywhere after it.
assert!(!contains_spaced_episode("Season 1 has no further structure", 1, 2));
assert!(!contains_spaced_episode(
"Season 1 has no further structure",
1,
2
));
}
#[test]

View file

@ -1,4 +1,6 @@
use crate::{cast_protocol, headless_engine, offline_download, player_policy, stream_policy};
use crate::stream_policy;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
use crate::{cast_protocol, headless_engine, offline_download, player_policy};
pub struct FluxaCore;
@ -9,90 +11,155 @@ fn guard<T>(default: T, f: impl FnOnce() -> T) -> T {
}
impl FluxaCore {
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn create_headless_engine(initial_json: &str) -> u64 {
guard(0, || headless_engine::create_headless_engine(initial_json))
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn headless_engine_snapshot_json(handle: u64) -> Option<String> {
guard(None, || headless_engine::headless_engine_snapshot_json(handle))
guard(None, || {
headless_engine::headless_engine_snapshot_json(handle)
})
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn headless_engine_dispatch_json(handle: u64, action_json: &str) -> Option<String> {
guard(None, || headless_engine::headless_engine_dispatch_json(handle, action_json))
guard(None, || {
headless_engine::headless_engine_dispatch_json(handle, action_json)
})
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn headless_engine_complete_effect_json(handle: u64, result_json: &str) -> Option<String> {
guard(None, || headless_engine::headless_engine_complete_effect_json(handle, result_json))
guard(None, || {
headless_engine::headless_engine_complete_effect_json(handle, result_json)
})
}
pub fn stream_playback_info_json(stream_json: &str) -> Option<String> {
guard(None, || stream_policy::stream_playback_info_json(stream_json))
guard(None, || {
stream_policy::stream_playback_info_json(stream_json)
})
}
pub fn torrent_runtime_info_json(request_json: &str) -> Option<String> {
guard(None, || stream_policy::torrent_runtime_info_json(request_json))
guard(None, || {
stream_policy::torrent_runtime_info_json(request_json)
})
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn player_buffer_targets_json(request_json: &str) -> Option<String> {
guard(None, || player_policy::player_buffer_targets_json(request_json))
guard(None, || {
player_policy::player_buffer_targets_json(request_json)
})
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn offline_download_plan_json(request_json: &str) -> Option<String> {
guard(None, || offline_download::offline_download_plan_json(request_json))
guard(None, || {
offline_download::offline_download_plan_json(request_json)
})
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn validate_stream_url(url: &str) -> bool {
guard(false, || cast_protocol::validate_stream_url(url))
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn dlna_parse_device_description(xml: &str, base_url: &str) -> Option<String> {
guard(None, || cast_protocol::dlna_parse_device_description_json(xml, base_url))
guard(None, || {
cast_protocol::dlna_parse_device_description_json(xml, base_url)
})
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn dlna_soap_action_body(urn: &str, action: &str, args: &str) -> String {
guard(String::new(), || cast_protocol::soap_action_body(urn, action, args))
guard(String::new(), || {
cast_protocol::soap_action_body(urn, action, args)
})
}
pub fn dlna_set_av_transport_args(media_url: &str, title: &str, subtitle_url: Option<&str>) -> Option<String> {
guard(None, || cast_protocol::dlna_set_av_transport_args(media_url, title, subtitle_url))
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn dlna_set_av_transport_args(
media_url: &str,
title: &str,
subtitle_url: Option<&str>,
) -> Option<String> {
guard(None, || {
cast_protocol::dlna_set_av_transport_args(media_url, title, subtitle_url)
})
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn dlna_seek_args(position_secs: f64) -> String {
guard(String::new(), || cast_protocol::dlna_seek_args(position_secs))
guard(String::new(), || {
cast_protocol::dlna_seek_args(position_secs)
})
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn dlna_set_volume_args(level: f64) -> String {
guard(String::new(), || cast_protocol::dlna_set_volume_args(level))
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn dlna_resolve_loopback_url(stream_url: &str, lan_ip: &str) -> String {
guard(stream_url.to_string(), || cast_protocol::resolve_loopback_url(stream_url, lan_ip))
guard(stream_url.to_string(), || {
cast_protocol::resolve_loopback_url(stream_url, lan_ip)
})
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn chromecast_guess_content_type(media_url: &str) -> String {
guard("video/mp4".to_string(), || cast_protocol::guess_cast_content_type(media_url).to_string())
guard("video/mp4".to_string(), || {
cast_protocol::guess_cast_content_type(media_url).to_string()
})
}
pub fn chromecast_encode_message(source_id: &str, destination_id: &str, namespace: &str, payload_utf8: &str) -> Vec<u8> {
guard(Vec::new(), || cast_protocol::encode_cast_message(source_id, destination_id, namespace, payload_utf8))
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn chromecast_encode_message(
source_id: &str,
destination_id: &str,
namespace: &str,
payload_utf8: &str,
) -> Vec<u8> {
guard(Vec::new(), || {
cast_protocol::encode_cast_message(source_id, destination_id, namespace, payload_utf8)
})
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn chromecast_decode_message(buf: &[u8]) -> Option<(String, String)> {
guard(None, || cast_protocol::decode_cast_message(buf).map(|m| (m.namespace, m.payload_utf8)))
guard(None, || {
cast_protocol::decode_cast_message(buf).map(|m| (m.namespace, m.payload_utf8))
})
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn roku_device_name(xml: &str) -> Option<String> {
guard(None, || cast_protocol::roku_device_name(xml))
}
pub fn roku_launch_url(host: &str, media_url: &str, subtitle_url: Option<&str>) -> Option<String> {
guard(None, || cast_protocol::roku_launch_url(host, media_url, subtitle_url))
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn roku_launch_url(
host: &str,
media_url: &str,
subtitle_url: Option<&str>,
) -> Option<String> {
guard(None, || {
cast_protocol::roku_launch_url(host, media_url, subtitle_url)
})
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn airplay_volume_db(level: f64) -> f64 {
guard(-30.0, || cast_protocol::airplay_volume_db(level))
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn airplay_play_body(media_url: &str) -> Option<String> {
guard(None, || cast_protocol::airplay_play_body(media_url))
}

View file

@ -1,5 +1,5 @@
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use ::dolby_vision::rpu::dovi_rpu::DoviRpu;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
@ -84,7 +84,9 @@ pub fn dolby_vision_convert_rpu_json(input: &str) -> Option<String> {
rpu_base64: Some(BASE64.encode(&out)),
error: None,
},
Err(error) => convert_error(profile_before, el_type_before, error.to_string()),
Err(error) => {
convert_error(profile_before, el_type_before, error.to_string())
}
},
Err(error) => convert_error(profile_before, el_type_before, error.to_string()),
}
@ -186,6 +188,9 @@ mod tests {
// to panic (byte-range slicing assumed 1 char == 1 byte); it must now
// surface as an error.
assert!(hex_decode("aébb").is_err());
assert!(hex_decode("abc").is_err(), "odd length must error, not panic");
assert!(
hex_decode("abc").is_err(),
"odd length must error, not panic"
);
}
}

View file

@ -239,7 +239,10 @@ fn trakt_id_from_source(source: &Value) -> Option<String> {
pub(crate) fn trakt_playback_items_to_library_json(items_json: &str) -> Option<String> {
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
let result: Vec<Value> = items.iter().filter_map(trakt_playback_item_to_library).collect();
let result: Vec<Value> = items
.iter()
.filter_map(trakt_playback_item_to_library)
.collect();
serde_json::to_string(&result).ok()
}
@ -250,11 +253,21 @@ fn trakt_playback_item_to_library(item: &Value) -> Option<Value> {
let source = movie.or(show)?;
let id = trakt_id_from_source(source)?;
let progress = item.get("progress").and_then(Value::as_f64).unwrap_or(0.0);
if progress < 1.0 { return None; }
let title = source.get("title").or_else(|| source.get("name"))
.and_then(Value::as_str).unwrap_or("Untitled");
let episode_title = episode.and_then(|e| e.get("title")).and_then(Value::as_str).unwrap_or("");
let ep_runtime = episode.and_then(|e| e.get("runtime")).and_then(Value::as_f64);
if progress < 1.0 {
return None;
}
let title = source
.get("title")
.or_else(|| source.get("name"))
.and_then(Value::as_str)
.unwrap_or("Untitled");
let episode_title = episode
.and_then(|e| e.get("title"))
.and_then(Value::as_str)
.unwrap_or("");
let ep_runtime = episode
.and_then(|e| e.get("runtime"))
.and_then(Value::as_f64);
let runtime_min = ep_runtime
.or_else(|| source.get("runtime").and_then(Value::as_f64))
.unwrap_or(if movie.is_some() { 100.0 } else { 45.0 });
@ -273,8 +286,12 @@ fn trakt_playback_item_to_library(item: &Value) -> Option<Value> {
} else {
id.clone()
};
let episode_season = episode.and_then(|e| e.get("season")).and_then(Value::as_i64);
let episode_number = episode.and_then(|e| e.get("number")).and_then(Value::as_i64);
let episode_season = episode
.and_then(|e| e.get("season"))
.and_then(Value::as_i64);
let episode_number = episode
.and_then(|e| e.get("number"))
.and_then(Value::as_i64);
let saved_at = item.get("paused_at").and_then(Value::as_str).unwrap_or("");
Some(json!({
"id": id,
@ -315,7 +332,8 @@ pub(crate) fn trakt_watched_to_ids_json(movies_json: &str, shows_json: &str) ->
let shows: Vec<Value> = serde_json::from_str(shows_json).unwrap_or_default();
let mut ids: serde_json::Map<String, Value> = serde_json::Map::new();
for entry in &movies {
if let Some(imdb) = entry.get("movie")
if let Some(imdb) = entry
.get("movie")
.and_then(|m| m.get("ids"))
.and_then(|ids| ids.get("imdb"))
.and_then(Value::as_str)
@ -325,7 +343,8 @@ pub(crate) fn trakt_watched_to_ids_json(movies_json: &str, shows_json: &str) ->
}
}
for entry in &shows {
let imdb = match entry.get("show")
let imdb = match entry
.get("show")
.and_then(|s| s.get("ids"))
.and_then(|ids| ids.get("imdb"))
.and_then(Value::as_str)
@ -334,10 +353,18 @@ pub(crate) fn trakt_watched_to_ids_json(movies_json: &str, shows_json: &str) ->
Some(s) => s,
None => continue,
};
let seasons = entry.get("seasons").and_then(Value::as_array).cloned().unwrap_or_default();
let seasons = entry
.get("seasons")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
for season in &seasons {
let s_num = season.get("number").and_then(Value::as_i64).unwrap_or(0);
let episodes = season.get("episodes").and_then(Value::as_array).cloned().unwrap_or_default();
let episodes = season
.get("episodes")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
for ep in &episodes {
let e_num = ep.get("number").and_then(Value::as_i64).unwrap_or(0);
if s_num > 0 && e_num > 0 {
@ -352,7 +379,8 @@ pub(crate) fn trakt_watched_to_ids_json(movies_json: &str, shows_json: &str) ->
pub(crate) fn merge_external_watchlist_json(local_json: &str, external_json: &str) -> String {
let mut local: Vec<Value> = serde_json::from_str(local_json).unwrap_or_default();
let external: Vec<Value> = serde_json::from_str(external_json).unwrap_or_default();
let local_ids: std::collections::HashSet<String> = local.iter()
let local_ids: std::collections::HashSet<String> = local
.iter()
.filter_map(|i| i.get("id").and_then(Value::as_str).map(str::to_string))
.collect();
for item in external {
@ -366,8 +394,10 @@ pub(crate) fn merge_external_watchlist_json(local_json: &str, external_json: &st
}
pub(crate) fn merge_external_watched_json(local_json: &str, external_json: &str) -> String {
let mut local: serde_json::Map<String, Value> = serde_json::from_str(local_json).unwrap_or_default();
let external: serde_json::Map<String, Value> = serde_json::from_str(external_json).unwrap_or_default();
let mut local: serde_json::Map<String, Value> =
serde_json::from_str(local_json).unwrap_or_default();
let external: serde_json::Map<String, Value> =
serde_json::from_str(external_json).unwrap_or_default();
for (id, val) in external {
if val.as_bool() == Some(true) && !local.contains_key(&id) {
local.insert(id, Value::Bool(true));
@ -383,29 +413,33 @@ pub(crate) fn merge_continue_watching_lists_json(
) -> Option<String> {
let local: Vec<Value> = serde_json::from_str(local_json).unwrap_or_default();
let external: Vec<Value> = serde_json::from_str(external_json).unwrap_or_default();
let progress: serde_json::Map<String, Value> = serde_json::from_str(progress_json).unwrap_or_default();
let progress: serde_json::Map<String, Value> =
serde_json::from_str(progress_json).unwrap_or_default();
fn item_id(item: &Value) -> String {
item.get("id").or_else(|| item.get("_id"))
.and_then(Value::as_str).unwrap_or("").to_string()
item.get("id")
.or_else(|| item.get("_id"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
}
fn saved_at_ms(item: &Value) -> i64 {
item.get("savedAt").and_then(Value::as_str)
item.get("savedAt")
.and_then(Value::as_str)
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt: chrono::DateTime<chrono::FixedOffset>| dt.timestamp_millis())
.unwrap_or(0)
}
let local_by_id: std::collections::HashMap<String, &Value> = local.iter()
.map(|item| (item_id(item), item))
.collect();
let external_by_id: std::collections::HashMap<String, &Value> = external.iter()
.map(|item| (item_id(item), item))
.collect();
let local_by_id: std::collections::HashMap<String, &Value> =
local.iter().map(|item| (item_id(item), item)).collect();
let external_by_id: std::collections::HashMap<String, &Value> =
external.iter().map(|item| (item_id(item), item)).collect();
fn local_saved_at_from_progress(progress: &serde_json::Map<String, Value>, id: &str) -> i64 {
progress.get(id)
progress
.get(id)
.and_then(|entry| entry.get("savedAt"))
.and_then(Value::as_str)
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
@ -442,11 +476,19 @@ pub(crate) fn simkl_watching_to_items_json(shows_json: &str, movies_json: &str)
for entry in &shows {
let show = entry.get("show")?;
let ids = show.get("ids")?;
let imdb = ids.get("imdb").and_then(Value::as_str).filter(|s| !s.is_empty())?;
let imdb = ids
.get("imdb")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())?;
let title = show.get("title").and_then(Value::as_str).unwrap_or("");
let poster = show.get("poster").and_then(Value::as_str)
let poster = show
.get("poster")
.and_then(Value::as_str)
.map(|p| format!("https://simkl.in/posters/{p}_m.jpg"));
let saved_at = entry.get("last_watched").and_then(Value::as_str).unwrap_or_default();
let saved_at = entry
.get("last_watched")
.and_then(Value::as_str)
.unwrap_or_default();
items.push(json!({
"id": imdb, "type": "series", "name": title,
"poster": poster, "continueWatchingBadge": "upNext",
@ -456,11 +498,19 @@ pub(crate) fn simkl_watching_to_items_json(shows_json: &str, movies_json: &str)
for entry in &movies {
let movie = entry.get("movie")?;
let ids = movie.get("ids")?;
let imdb = ids.get("imdb").and_then(Value::as_str).filter(|s| !s.is_empty())?;
let imdb = ids
.get("imdb")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())?;
let title = movie.get("title").and_then(Value::as_str).unwrap_or("");
let poster = movie.get("poster").and_then(Value::as_str)
let poster = movie
.get("poster")
.and_then(Value::as_str)
.map(|p| format!("https://simkl.in/posters/{p}_m.jpg"));
let saved_at = entry.get("last_watched").and_then(Value::as_str).unwrap_or_default();
let saved_at = entry
.get("last_watched")
.and_then(Value::as_str)
.unwrap_or_default();
items.push(json!({
"id": imdb, "type": "movie", "name": title,
"poster": poster, "savedAt": saved_at, "reason": "simkl"
@ -476,18 +526,28 @@ pub(crate) fn simkl_watchlist_to_items_json(shows_json: &str, movies_json: &str)
for entry in &shows {
let show = entry.get("show")?;
let ids = show.get("ids")?;
let imdb = ids.get("imdb").and_then(Value::as_str).filter(|s| !s.is_empty())?;
let imdb = ids
.get("imdb")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())?;
let title = show.get("title").and_then(Value::as_str).unwrap_or("");
let poster = show.get("poster").and_then(Value::as_str)
let poster = show
.get("poster")
.and_then(Value::as_str)
.map(|p| format!("https://simkl.in/posters/{p}_m.jpg"));
items.push(json!({ "id": imdb, "name": title, "type": "series", "source": "simkl", "poster": poster }));
}
for entry in &movies {
let movie = entry.get("movie")?;
let ids = movie.get("ids")?;
let imdb = ids.get("imdb").and_then(Value::as_str).filter(|s| !s.is_empty())?;
let imdb = ids
.get("imdb")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())?;
let title = movie.get("title").and_then(Value::as_str).unwrap_or("");
let poster = movie.get("poster").and_then(Value::as_str)
let poster = movie
.get("poster")
.and_then(Value::as_str)
.map(|p| format!("https://simkl.in/posters/{p}_m.jpg"));
items.push(json!({ "id": imdb, "name": title, "type": "movie", "source": "simkl", "poster": poster }));
}
@ -499,7 +559,8 @@ pub(crate) fn simkl_watched_to_ids_json(shows_json: &str, movies_json: &str) ->
let movies: Vec<Value> = serde_json::from_str(movies_json).unwrap_or_default();
let mut ids: serde_json::Map<String, Value> = serde_json::Map::new();
for entry in &shows {
if let Some(imdb) = entry.get("show")
if let Some(imdb) = entry
.get("show")
.and_then(|s| s.get("ids"))
.and_then(|i| i.get("imdb"))
.and_then(Value::as_str)
@ -509,7 +570,8 @@ pub(crate) fn simkl_watched_to_ids_json(shows_json: &str, movies_json: &str) ->
}
}
for entry in &movies {
if let Some(imdb) = entry.get("movie")
if let Some(imdb) = entry
.get("movie")
.and_then(|m| m.get("ids"))
.and_then(|i| i.get("imdb"))
.and_then(Value::as_str)
@ -536,7 +598,10 @@ pub(crate) fn replace_external_continue_watching_json(
.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 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
})
@ -554,15 +619,29 @@ pub(crate) fn replace_external_continue_watching_json(
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();
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); }
if item_time.as_str() > prev_time {
by_id.insert(id, item);
}
}
None => {
by_id.insert(id, item);
}
None => { by_id.insert(id, item); }
}
}
@ -573,20 +652,28 @@ pub(crate) fn replace_external_continue_watching_json(
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 {
fn saved_at_str(item: &Value) -> &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();
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); }
None => {
best.insert(id, item);
}
Some(existing) if cur.as_str() > saved_at_str(existing) => {
best.insert(id, item);
}
_ => {}
}
}
@ -599,8 +686,10 @@ pub(crate) fn trakt_playback_items_dedup_json(items_json: &str) -> Option<String
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();
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);
@ -617,7 +706,11 @@ pub(crate) fn trakt_mark_watched_body_json(video_ids_json: &str) -> Option<Strin
Err(_) => continue,
};
if parsed.get("isEpisode").and_then(Value::as_bool).unwrap_or(false) {
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
@ -629,7 +722,9 @@ pub(crate) fn trakt_mark_watched_body_json(video_ids_json: &str) -> Option<Strin
if show_id.is_empty() {
continue;
}
let entry = shows.entry(show_id).or_insert_with(|| (ids, std::collections::BTreeMap::new()));
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 }));
@ -670,7 +765,10 @@ pub(crate) fn trakt_mark_watched_body_json(video_ids_json: &str) -> Option<Strin
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 release_date = target
.get("releaseDate")
.and_then(Value::as_str)
.unwrap_or("");
let title = target
.get("title")
.and_then(Value::as_str)
@ -762,7 +860,13 @@ mod tests {
#[test]
fn trakt_mark_watched_body_groups_episodes_by_show_and_dedupes() {
let body = trakt_mark_watched_body_json(
&json!(["tt1234567:1:1", "tt1234567:1:2", "tt1234567:1:1", "tt7654321"]).to_string(),
&json!([
"tt1234567:1:1",
"tt1234567:1:2",
"tt1234567:1:1",
"tt7654321"
])
.to_string(),
)
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
.expect("body");
@ -786,6 +890,9 @@ mod tests {
#[test]
fn trakt_mark_watched_body_is_none_for_unrecognized_ids() {
assert_eq!(trakt_mark_watched_body_json(&json!(["not-an-id"]).to_string()), None);
assert_eq!(
trakt_mark_watched_body_json(&json!(["not-an-id"]).to_string()),
None
);
}
}

View file

@ -32,7 +32,10 @@ struct CallError {
}
fn fail(kind: ErrorKind, message: impl Into<String>) -> CallError {
CallError { kind, message: message.into() }
CallError {
kind,
message: message.into(),
}
}
type Outcome = Result<Value, CallError>;
@ -41,7 +44,8 @@ pub fn core_invoke(method: &str, args_json: &str) -> String {
// A panic anywhere in route()/the domain modules must not take the host
// process down with it — catch it here and hand back the same error
// envelope shape callers already handle for any other failure.
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| route(method, args_json)));
let outcome =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| route(method, args_json)));
match outcome {
Ok(Ok(value)) => json!({ "ok": true, "value": value }).to_string(),
Ok(Err(e)) => json!({
@ -85,16 +89,24 @@ const ROUTERS: &[fn(&str, &str) -> Outcome] = &[
fn route(method: &str, args_json: &str) -> Outcome {
for router in ROUTERS {
match router(method, args_json) {
Err(CallError { kind: ErrorKind::UnknownMethod, .. }) => continue,
Err(CallError {
kind: ErrorKind::UnknownMethod,
..
}) => continue,
result => return result,
}
}
Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`")))
Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
))
}
fn route_engine_lifecycle(method: &str, args_json: &str) -> Outcome {
match method {
"engine.create" => Ok(json!(headless_engine::create_headless_engine(args_json) as i64)),
"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,
@ -119,7 +131,9 @@ fn route_engine_lifecycle(method: &str, args_json: &str) -> Outcome {
method,
)
}
"engine.destroy" => Ok(json!(headless_engine::destroy_headless_engine(handle(args_json)?))),
"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)),
@ -136,15 +150,24 @@ fn route_engine_lifecycle(method: &str, args_json: &str) -> Outcome {
}
"app.destroy" => Ok(json!(app_state::destroy_app_core_state(handle(args_json)?))),
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}
fn route_addon_protocol(method: &str, args_json: &str) -> Outcome {
match method {
"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")?)),
"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(
@ -154,11 +177,16 @@ fn route_addon_protocol(method: &str, args_json: &str) -> Outcome {
))
}
// args_json IS the descriptor object
"resolveManifestAssets" => opt_json(addon_protocol::resolve_manifest_assets_json(args_json)),
"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");
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(),
@ -167,7 +195,10 @@ fn route_addon_protocol(method: &str, args_json: &str) -> Outcome {
}
"buildResourceUrl" => {
let args = object(args_json)?;
let extra = args.get("extraJson").and_then(Value::as_str).map(str::to_string);
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")?,
@ -178,7 +209,10 @@ fn route_addon_protocol(method: &str, args_json: &str) -> Outcome {
}
"supportsResource" => {
let args = object(args_json)?;
let content_type = args.get("contentType").and_then(Value::as_str).map(str::to_string);
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")?,
@ -209,7 +243,10 @@ fn route_addon_protocol(method: &str, args_json: &str) -> Outcome {
)))
}
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}
@ -218,8 +255,10 @@ fn route_addon_resource(method: &str, args_json: &str) -> Outcome {
"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;
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")?,
@ -235,23 +274,32 @@ fn route_addon_resource(method: &str, args_json: &str) -> Outcome {
))
}
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}
fn route_resource_plan(method: &str, args_json: &str) -> Outcome {
match method {
// Repository / resource flow — args_json IS the request object
"addonResourceRequestPlan" => opt_json(repository_flow::addon_resource_request_plan_json(args_json)),
"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)),
"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)),
"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)?;
@ -269,7 +317,10 @@ fn route_resource_plan(method: &str, args_json: &str) -> Outcome {
))
}
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}
@ -280,9 +331,18 @@ fn route_stream_policy(method: &str, args_json: &str) -> Outcome {
"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);
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(),
@ -291,7 +351,10 @@ fn route_stream_policy(method: &str, args_json: &str) -> Outcome {
)))
}
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}
@ -299,7 +362,9 @@ fn route_search_plan(method: &str, args_json: &str) -> Outcome {
match method {
// 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)),
"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(
@ -309,7 +374,9 @@ fn route_search_plan(method: &str, args_json: &str) -> Outcome {
}
"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")?))),
"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)?;
@ -326,17 +393,24 @@ fn route_search_plan(method: &str, args_json: &str) -> Outcome {
))
}
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}
fn route_player_policy(method: &str, args_json: &str) -> Outcome {
match method {
// args_json IS the request object for single-arg methods
"playerBackendSelection" => opt_json(player_policy::player_backend_selection_json(args_json)),
"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)),
"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(
@ -353,7 +427,10 @@ fn route_player_policy(method: &str, args_json: &str) -> Outcome {
))
}
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}
@ -361,7 +438,9 @@ fn route_watchlist(method: &str, args_json: &str) -> Outcome {
match method {
// 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)),
"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(
@ -379,7 +458,10 @@ fn route_watchlist(method: &str, args_json: &str) -> Outcome {
"importCollections" => opt_json(watchlist_plan::import_collections_json(args_json)),
"exportCollections" => opt_json(watchlist_plan::export_collections_json(args_json)),
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}
@ -388,15 +470,24 @@ fn route_offline(method: &str, args_json: &str) -> Outcome {
// args_json IS the request object
"offlineDownloadPlan" => opt_json(offline_download::offline_download_plan_json(args_json)),
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}
fn route_content_identity(method: &str, args_json: &str) -> Outcome {
match method {
"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")?))),
"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(
@ -406,8 +497,10 @@ fn route_content_identity(method: &str, args_json: &str) -> Outcome {
}
"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;
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")?,
@ -416,7 +509,10 @@ fn route_content_identity(method: &str, args_json: &str) -> Outcome {
))
}
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}
@ -438,7 +534,8 @@ fn route_calendar(method: &str, args_json: &str) -> Outcome {
}
"nextUnairedEpisode" => {
let args = object(args_json)?;
let now_ms = field(&args, "nowMs")?.as_i64()
let now_ms = field(&args, "nowMs")?
.as_i64()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "nowMs must be a number"))?;
opt_json(calendar_plan::next_unaired_episode_json(
field_str(&args, "videosJson")?,
@ -446,14 +543,19 @@ fn route_calendar(method: &str, args_json: &str) -> Outcome {
))
}
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}
fn route_external_sync_trakt(method: &str, args_json: &str) -> Outcome {
match method {
// args_json IS the items array for single-array-arg methods
"traktPlaybackItemsToLibrary" => opt_json(external_sync::trakt_playback_items_to_library_json(args_json)),
"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(
@ -494,15 +596,18 @@ fn route_external_sync_trakt(method: &str, args_json: &str) -> Outcome {
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()
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()
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()
field(&args, "isEpisode")?
.as_bool()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "isEpisode must be bool"))?,
season,
ep_number,
@ -519,10 +624,15 @@ fn route_external_sync_trakt(method: &str, args_json: &str) -> Outcome {
field_str(&args, "itemsJson")?,
))
}
"traktPlaybackItemsDedup" => opt_json(external_sync::trakt_playback_items_dedup_json(args_json)),
"traktPlaybackItemsDedup" => {
opt_json(external_sync::trakt_playback_items_dedup_json(args_json))
}
"traktMarkWatchedBody" => opt_json(external_sync::trakt_mark_watched_body_json(args_json)),
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}
@ -551,17 +661,22 @@ fn route_external_sync_simkl(method: &str, args_json: &str) -> Outcome {
}
"simklScrobbleBody" => {
let args = object(args_json)?;
let season = field(&args, "season")?.as_i64()
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()
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()
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()
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()
field(&args, "isEpisode")?
.as_bool()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "isEpisode must be bool"))?,
season,
ep_number,
@ -577,17 +692,28 @@ fn route_external_sync_simkl(method: &str, args_json: &str) -> Outcome {
))
}
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}
fn route_library_state(method: &str, args_json: &str) -> Outcome {
match method {
// 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)),
"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(
@ -597,7 +723,8 @@ fn route_library_state(method: &str, args_json: &str) -> Outcome {
}
"computeContinueWatchingBadges" => {
let args = object(args_json)?;
let now_ms = field(&args, "nowMs")?.as_i64()
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")?,
@ -610,10 +737,18 @@ fn route_library_state(method: &str, args_json: &str) -> Outcome {
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"))?,
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" => {
@ -630,7 +765,9 @@ fn route_library_state(method: &str, args_json: &str) -> Outcome {
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"))?,
field(&args, "isHorizontal")?
.as_bool()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "isHorizontal must be bool"))?,
)))
}
"continueWatchingCardFields" => {
@ -638,7 +775,9 @@ fn route_library_state(method: &str, args_json: &str) -> Outcome {
opt_json(library_state::continue_watching_card_fields_json(
&field(&args, "items")?.to_string(),
field_str(&args, "artworkPreference")?,
field(&args, "isHorizontal")?.as_bool().ok_or_else(|| fail(ErrorKind::InvalidArgs, "isHorizontal must be bool"))?,
field(&args, "isHorizontal")?
.as_bool()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "isHorizontal must be bool"))?,
))
}
"buildHomeCollectionShelves" => {
@ -649,14 +788,21 @@ fn route_library_state(method: &str, args_json: &str) -> Outcome {
))
}
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}
fn route_tmdb(method: &str, args_json: &str) -> Outcome {
match method {
"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")?))),
"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(
@ -682,13 +828,19 @@ fn route_tmdb(method: &str, args_json: &str) -> Outcome {
field_str(&args, "language")?,
))
}
"tmdbBulkVideosToTrailers" => opt_json(tmdb_plan::tmdb_bulk_videos_to_trailers_json(args_json)),
"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")?);
let (content_type, is_movie) =
tmdb_plan::tmdb_resolve_id_hint(&arg_str(args_json, "contentId")?);
Ok(json!([content_type, is_movie]))
}
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}
@ -706,31 +858,48 @@ fn route_intro_segments(method: &str, args_json: &str) -> Outcome {
}
"mergeIntroSegments" => opt_json(intro_segments::merge_intro_segments_json(args_json)),
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}
fn route_core_contract(method: &str, args_json: &str) -> Outcome {
match method {
"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),
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}`"))),
_ => 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}")))?,
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}")))?;
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 {
@ -749,36 +918,58 @@ fn field<'a>(args: &'a Value, name: &str) -> Result<&'a Value, CallError> {
}
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")))
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"))
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}")))?;
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 })"))
.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"))),
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}")))
serde_json::from_str(&s).map_err(|e| {
fail(
ErrorKind::Internal,
format!("core produced invalid JSON: {e}"),
)
})
}
#[cfg(test)]

View file

@ -39,9 +39,9 @@ struct PrefetchPlanRequest {
pub(crate) fn provider_availability_plan_json(request_json: &str) -> Option<String> {
let request = serde_json::from_str::<ProviderAvailabilityRequest>(request_json).ok()?;
let has_stremio_stream_addon = request.addons.iter().any(|addon| {
addon
.get("manifest")
.is_some_and(|manifest| addon_protocol::supports_resource(&manifest.to_string(), "stream", None, None))
addon.get("manifest").is_some_and(|manifest| {
addon_protocol::supports_resource(&manifest.to_string(), "stream", None, None)
})
});
let plugin_names = stable_non_empty_strings(request.plugin_names);
serde_json::to_string(&json!({

View file

@ -103,7 +103,8 @@ pub(super) fn dispatch_resource(
id,
extra: extra.unwrap_or(Value::Null),
};
engine.state.addons.last_resource_request = serde_json::to_value(&payload).unwrap_or(Value::Null);
engine.state.addons.last_resource_request =
serde_json::to_value(&payload).unwrap_or(Value::Null);
vec![engine.effect(EffectKind::FetchAddonResource, generation, payload)]
}

View file

@ -40,7 +40,11 @@ struct RefreshAuthTokenPayload {
profile: Value,
}
pub(super) fn dispatch_flow(engine: &mut HeadlessEngine, provider: String, mode: String) -> Vec<EffectEnvelope> {
pub(super) fn dispatch_flow(
engine: &mut HeadlessEngine,
provider: String,
mode: String,
) -> Vec<EffectEnvelope> {
let generation = engine.bump_generation(GenerationKey::Auth);
engine.state.auth = AuthState {
provider: provider.clone(),
@ -50,7 +54,11 @@ pub(super) fn dispatch_flow(engine: &mut HeadlessEngine, provider: String, mode:
error: Value::Null,
generation,
};
vec![engine.effect(EffectKind::RunAuthFlow, generation, RunAuthFlowPayload { provider, mode })]
vec![engine.effect(
EffectKind::RunAuthFlow,
generation,
RunAuthFlowPayload { provider, mode },
)]
}
pub(super) fn dispatch_exchange(
@ -81,7 +89,11 @@ pub(super) fn dispatch_exchange(
)]
}
pub(super) fn dispatch_token_refresh(engine: &mut HeadlessEngine, provider: String, profile: Value) -> Vec<EffectEnvelope> {
pub(super) fn dispatch_token_refresh(
engine: &mut HeadlessEngine,
provider: String,
profile: Value,
) -> Vec<EffectEnvelope> {
let generation = engine.bump_generation(GenerationKey::Auth);
engine.state.auth = AuthState {
provider: provider.clone(),
@ -91,7 +103,11 @@ pub(super) fn dispatch_token_refresh(engine: &mut HeadlessEngine, provider: Stri
error: Value::Null,
generation,
};
vec![engine.effect(EffectKind::RefreshAuthToken, generation, RefreshAuthTokenPayload { provider, profile })]
vec![engine.effect(
EffectKind::RefreshAuthToken,
generation,
RefreshAuthTokenPayload { provider, profile },
)]
}
pub(super) fn complete(

View file

@ -86,20 +86,43 @@ pub(super) fn complete(
}
engine.state.calendar.is_loading = false;
if result.status == "ok" {
let items = result.value.get("items").cloned().unwrap_or_else(|| result.value.clone());
let local_items = result.value.get("localItems").cloned().unwrap_or_else(|| serde_json::json!([]));
let external_items = result.value.get("externalItems").cloned().unwrap_or_else(|| serde_json::json!([]));
let items = result
.value
.get("items")
.cloned()
.unwrap_or_else(|| result.value.clone());
let local_items = result
.value
.get("localItems")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
let external_items = result
.value
.get("externalItems")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.calendar.items = items.clone();
engine.state.calendar.local_items = local_items;
engine.state.calendar.external_items = external_items.clone();
engine.state.calendar.error = Value::Null;
let profile = effect.payload.get("profile").cloned().unwrap_or(Value::Null);
let profile_id = effect.payload.get("profileId").cloned().unwrap_or(Value::Null);
let profile = effect
.payload
.get("profile")
.cloned()
.unwrap_or(Value::Null);
let profile_id = effect
.payload
.get("profileId")
.cloned()
.unwrap_or(Value::Null);
let mut follow_up = vec![
engine.effect(
EffectKind::UpdateCalendarWidget,
generation,
CalendarItemsPayload { profile: profile.clone(), items: items.clone() },
CalendarItemsPayload {
profile: profile.clone(),
items: items.clone(),
},
),
engine.effect(
EffectKind::NotifyReleasedEpisodes,
@ -111,7 +134,10 @@ pub(super) fn complete(
follow_up.push(engine.effect(
EffectKind::ReplaceExternalContinueWatching,
generation,
ReplaceExternalContinueWatchingPayload { profile_id, items: external_items },
ReplaceExternalContinueWatchingPayload {
profile_id,
items: external_items,
},
));
}
follow_up

View file

@ -149,6 +149,11 @@ pub(super) enum AppAction {
language: Option<String>,
force: Option<bool>,
},
#[serde(rename = "refreshContinueWatchingRequested")]
RefreshContinueWatchingRequested {
profile: Option<Value>,
language: Option<String>,
},
#[serde(rename = "libraryHydrateRequested")]
LibraryHydrateRequested { profile_id: Option<String> },
#[serde(rename = "toggleWatchlistRequested")]

View file

@ -1,5 +1,6 @@
use super::helpers::{
active_profile_id, normalize_error, normalize_meta_trailers, value_array_is_empty, visible_streams,
active_profile_id, normalize_error, normalize_meta_trailers, value_array_is_empty,
visible_streams,
};
use super::state::GenerationKey;
use super::{EffectResultInput, HeadlessEngine};
@ -216,7 +217,11 @@ pub(super) fn dispatch_load(
profile: profile.unwrap_or(Value::Null),
},
),
engine.effect(EffectKind::ReadPlaybackProgress, generation, ReadPlaybackProgressPayload { id }),
engine.effect(
EffectKind::ReadPlaybackProgress,
generation,
ReadPlaybackProgressPayload { id },
),
]
}
@ -335,17 +340,29 @@ pub(super) fn dispatch_streams_appended(
if !engine.state.detail.is_loading_streams {
return vec![];
}
let mut merged: Vec<Value> = engine.state.detail.streams.as_array().cloned().unwrap_or_default();
let mut merged: Vec<Value> = engine
.state
.detail
.streams
.as_array()
.cloned()
.unwrap_or_default();
merged.extend(streams);
engine.state.detail.streams = serde_json::json!(merged);
engine.state.detail.visible_streams =
visible_streams(&engine.state.detail.streams, engine.state.detail.selected_addon.as_str());
engine.state.detail.visible_streams = visible_streams(
&engine.state.detail.streams,
engine.state.detail.selected_addon.as_str(),
);
let mut all_addons: Vec<String> = engine
.state
.detail
.available_addons
.as_array()
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
.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) {
@ -353,17 +370,29 @@ pub(super) fn dispatch_streams_appended(
}
}
engine.state.detail.available_addons = serde_json::json!(all_addons);
engine.state.detail.has_stream_providers = Value::Bool(!value_array_is_empty(&engine.state.detail.streams));
engine.state.detail.has_stream_providers =
Value::Bool(!value_array_is_empty(&engine.state.detail.streams));
vec![]
}
pub(super) fn dispatch_selected_addon_changed(engine: &mut HeadlessEngine, addon: Option<String>) -> Vec<EffectEnvelope> {
pub(super) fn dispatch_selected_addon_changed(
engine: &mut HeadlessEngine,
addon: Option<String>,
) -> Vec<EffectEnvelope> {
let selected = addon.and_then(|value| {
let trimmed = value.trim().to_string();
if trimmed.is_empty() { None } else { Some(trimmed) }
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
});
engine.state.detail.selected_addon = selected.as_ref().map(|value| Value::String(value.clone())).unwrap_or(Value::Null);
engine.state.detail.visible_streams = visible_streams(&engine.state.detail.streams, selected.as_deref());
engine.state.detail.selected_addon = selected
.as_ref()
.map(|value| Value::String(value.clone()))
.unwrap_or(Value::Null);
engine.state.detail.visible_streams =
visible_streams(&engine.state.detail.streams, selected.as_deref());
vec![]
}
@ -436,28 +465,43 @@ pub(super) fn complete(
}
"readPlaybackProgress" => {
if generation == engine.state.runtime.get(GenerationKey::Detail) {
engine.state.detail.saved_playback = if result.status == "ok" { result.value.clone() } else { Value::Null };
engine.state.detail.saved_playback = if result.status == "ok" {
result.value.clone()
} else {
Value::Null
};
}
}
"readDetailLocalState" => {
if generation == engine.state.runtime.get(GenerationKey::Detail) {
if result.status == "ok" {
engine.state.detail.saved_playback = result.value.get("savedPlayback").cloned().unwrap_or(Value::Null);
engine.state.detail.saved_playback = result
.value
.get("savedPlayback")
.cloned()
.unwrap_or(Value::Null);
engine.state.detail.local_watched_video_ids = result
.value
.get("localWatchedVideoIds")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.detail.is_in_watchlist =
result.value.get("isInWatchlist").cloned().unwrap_or_else(|| Value::Bool(false));
engine.state.detail.feedback = result.value.get("feedback").cloned().unwrap_or(Value::Null);
engine.state.detail.is_in_watchlist = result
.value
.get("isInWatchlist")
.cloned()
.unwrap_or(Value::Bool(false));
engine.state.detail.feedback =
result.value.get("feedback").cloned().unwrap_or(Value::Null);
engine.state.detail.has_stream_providers = result
.value
.get("hasStreamProviders")
.cloned()
.unwrap_or_else(|| Value::Bool(false));
engine.state.detail.user_addons =
result.value.get("userAddons").cloned().unwrap_or_else(|| serde_json::json!([]));
.unwrap_or(Value::Bool(false));
engine.state.detail.user_addons = result
.value
.get("userAddons")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
} else {
engine.state.detail.error = normalize_error(result.error.clone());
}
@ -466,16 +510,33 @@ pub(super) fn complete(
"fetchDetailSecondary" => {
if generation == engine.state.runtime.get(GenerationKey::Detail) {
if result.status == "ok" {
engine.state.detail.watched_video_ids =
result.value.get("watchedVideoIds").cloned().unwrap_or_else(|| serde_json::json!([]));
engine.state.detail.similar_items =
result.value.get("similarItems").cloned().unwrap_or_else(|| serde_json::json!([]));
engine.state.detail.watched_video_ids = result
.value
.get("watchedVideoIds")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.detail.similar_items = result
.value
.get("similarItems")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
if value_array_is_empty(&engine.state.detail.trailers) {
engine.state.detail.trailers =
result.value.get("trailers").cloned().unwrap_or_else(|| serde_json::json!([]));
engine.state.detail.trailers = result
.value
.get("trailers")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
}
engine.state.detail.omdb_ratings = result.value.get("omdbRatings").cloned().unwrap_or(Value::Null);
engine.state.detail.fanart_artwork = result.value.get("fanartArtwork").cloned().unwrap_or(Value::Null);
engine.state.detail.omdb_ratings = result
.value
.get("omdbRatings")
.cloned()
.unwrap_or(Value::Null);
engine.state.detail.fanart_artwork = result
.value
.get("fanartArtwork")
.cloned()
.unwrap_or(Value::Null);
} else {
engine.state.detail.error = normalize_error(result.error.clone());
}
@ -494,16 +555,29 @@ pub(super) fn complete(
if generation == engine.state.runtime.get(GenerationKey::DetailStreams) {
engine.state.detail.is_loading_streams = false;
if result.status == "ok" {
engine.state.detail.streams =
result.value.get("streams").cloned().unwrap_or_else(|| serde_json::json!([]));
engine.state.detail.streams = result
.value
.get("streams")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.detail.selected_addon = Value::Null;
engine.state.detail.visible_streams = engine.state.detail.streams.clone();
engine.state.detail.available_addons =
result.value.get("availableAddons").cloned().unwrap_or_else(|| serde_json::json!([]));
engine.state.detail.available_addons = result
.value
.get("availableAddons")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.detail.loading_addon_names = serde_json::json!([]);
engine.state.detail.resolved_request_id = result.value.get("resolvedRequestId").cloned().unwrap_or(Value::Null);
engine.state.detail.has_stream_providers =
result.value.get("hasStreamProviders").cloned().unwrap_or_else(|| Value::Bool(false));
engine.state.detail.resolved_request_id = result
.value
.get("resolvedRequestId")
.cloned()
.unwrap_or(Value::Null);
engine.state.detail.has_stream_providers = result
.value
.get("hasStreamProviders")
.cloned()
.unwrap_or(Value::Bool(false));
engine.state.detail.streams_error = Value::Null;
} else {
engine.state.detail.streams_error = normalize_error(result.error.clone());
@ -532,8 +606,11 @@ pub(super) fn complete(
if generation == engine.state.runtime.get(GenerationKey::Detail) {
engine.state.detail.season_loading = Value::Null;
if result.status == "ok" {
engine.state.detail.season_episodes =
result.value.get("episodes").cloned().unwrap_or_else(|| result.value.clone());
engine.state.detail.season_episodes = result
.value
.get("episodes")
.cloned()
.unwrap_or_else(|| result.value.clone());
engine.state.detail.error = Value::Null;
} else {
engine.state.detail.error = normalize_error(result.error.clone());

View file

@ -127,10 +127,16 @@ pub(super) fn complete(
"readDiscoverCatalogFilters" => {
if generation == engine.state.runtime.get(GenerationKey::Discover) {
if result.status == "ok" {
engine.state.discover.catalogs =
result.value.get("catalogs").cloned().unwrap_or_else(|| serde_json::json!([]));
engine.state.discover.genres =
result.value.get("genres").cloned().unwrap_or_else(|| serde_json::json!([]));
engine.state.discover.catalogs = result
.value
.get("catalogs")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.discover.genres = result
.value
.get("genres")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.discover.error = Value::Null;
} else {
engine.state.discover.error = normalize_error(result.error.clone());

View file

@ -133,7 +133,9 @@ pub(super) fn upsert_by_key(target: &mut Value, key: &str, value: &str, item: Va
if !target.is_array() {
*target = json!([]);
}
let Some(items) = target.as_array_mut() else { return };
let Some(items) = target.as_array_mut() else {
return;
};
if let Some(existing) = items
.iter_mut()
.find(|existing| existing[key].as_str() == Some(value))

View file

@ -82,6 +82,33 @@ struct FetchCatalogPagePayload {
search: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RefreshContinueWatchingPayload {
profile_id: String,
profile: Value,
language: String,
}
pub(super) fn dispatch_refresh_continue_watching(
engine: &mut HeadlessEngine,
profile: Option<Value>,
language: Option<String>,
) -> Vec<EffectEnvelope> {
let profile_value = profile.unwrap_or_else(|| engine.state.profile.active.clone());
let profile_id = active_profile_id(&engine.state, &profile_value);
let generation = engine.state.runtime.get(GenerationKey::Home);
vec![engine.effect(
EffectKind::RefreshContinueWatching,
generation,
RefreshContinueWatchingPayload {
profile_id,
profile: profile_value,
language: language.unwrap_or_else(|| "en".to_string()),
},
)]
}
pub(super) fn remove_from_continue_watching(engine: &mut HeadlessEngine, dropped_id: &str) {
if let Some(items) = engine.state.home.continue_watching.as_array_mut() {
items.retain(|item| item.get("id").and_then(Value::as_str) != Some(dropped_id));
@ -145,6 +172,7 @@ pub(super) fn dispatch_direct_playback(
)]
}
#[allow(clippy::too_many_arguments)]
pub(super) fn dispatch_catalog_page(
engine: &mut HeadlessEngine,
category_id: String,
@ -185,27 +213,47 @@ pub(super) fn complete(
result: &EffectResultInput,
) -> Vec<EffectEnvelope> {
match effect_type {
"refreshContinueWatching" => {
if result.status == "ok" {
if let Some(cw) = result.value.get("continueWatching") {
engine.state.home.continue_watching = cw.clone();
}
}
}
"readHomeBootstrap" => {
if generation == engine.state.runtime.get(GenerationKey::Home) {
engine.state.home.is_loading = false;
if result.status == "ok" {
engine.state.home.categories =
result.value.get("categories").cloned().unwrap_or_else(|| serde_json::json!([]));
engine.state.home.categories = result
.value
.get("categories")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.home.continue_watching = result
.value
.get("continueWatching")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.home.watchlist =
result.value.get("watchlist").cloned().unwrap_or_else(|| serde_json::json!([]));
engine.state.home.user_addons =
result.value.get("userAddons").cloned().unwrap_or_else(|| serde_json::json!([]));
engine.state.home.watchlist = result
.value
.get("watchlist")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.home.user_addons = result
.value
.get("userAddons")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.home.metadata_feeds = result
.value
.get("metadataFeeds")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.home.billboard = result.value.get("billboard").cloned().unwrap_or(Value::Null);
engine.state.home.billboard = result
.value
.get("billboard")
.cloned()
.unwrap_or(Value::Null);
engine.state.home.error = Value::Null;
} else {
engine.state.home.error = normalize_error(result.error.clone());
@ -218,7 +266,11 @@ pub(super) fn complete(
if result.status == "ok" {
player::complete_direct_playback(engine, result.value.clone(), Value::Null);
} else {
player::complete_direct_playback(engine, Value::Null, Value::String(error_code(&result.error)));
player::complete_direct_playback(
engine,
Value::Null,
Value::String(error_code(&result.error)),
);
}
}
}
@ -226,8 +278,11 @@ pub(super) fn complete(
if generation == engine.state.runtime.get(GenerationKey::Home) {
engine.state.home.paging.is_loading = false;
if result.status == "ok" {
engine.state.home.paging.items =
result.value.get("items").cloned().unwrap_or_else(|| result.value.clone());
engine.state.home.paging.items = result
.value
.get("items")
.cloned()
.unwrap_or_else(|| result.value.clone());
engine.state.home.paging.error = Value::Null;
} else {
engine.state.home.paging.error = normalize_error(result.error.clone());

View file

@ -125,14 +125,21 @@ pub(super) fn set_active_profile_id(engine: &mut HeadlessEngine, id: &str) {
engine.state.library.active_profile_id = id.to_string();
}
pub(super) fn dispatch_profile_activated(engine: &mut HeadlessEngine, profile: Value) -> Vec<EffectEnvelope> {
pub(super) fn dispatch_profile_activated(
engine: &mut HeadlessEngine,
profile: Value,
) -> Vec<EffectEnvelope> {
profile::activate(engine, profile);
vec![]
}
pub(super) fn dispatch_hydrate(engine: &mut HeadlessEngine, profile_id: Option<String>) -> Vec<EffectEnvelope> {
pub(super) fn dispatch_hydrate(
engine: &mut HeadlessEngine,
profile_id: Option<String>,
) -> Vec<EffectEnvelope> {
let generation = engine.bump_generation(GenerationKey::Library);
let resolved_profile_id = profile_id.unwrap_or_else(|| active_profile_id(&engine.state, &Value::Null));
let resolved_profile_id =
profile_id.unwrap_or_else(|| active_profile_id(&engine.state, &Value::Null));
engine.state.library.active_profile_id = resolved_profile_id.clone();
engine.state.library.is_loading = true;
engine.state.library.error = Value::Null;
@ -140,47 +147,85 @@ pub(super) fn dispatch_hydrate(engine: &mut HeadlessEngine, profile_id: Option<S
vec![engine.effect(
EffectKind::ReadLibraryState,
generation,
ReadLibraryStatePayload { profile_id: resolved_profile_id },
ReadLibraryStatePayload {
profile_id: resolved_profile_id,
},
)]
}
pub(super) fn dispatch_toggle_watchlist(engine: &mut HeadlessEngine, item: Value) -> Vec<EffectEnvelope> {
pub(super) fn dispatch_toggle_watchlist(
engine: &mut HeadlessEngine,
item: Value,
) -> Vec<EffectEnvelope> {
let generation = engine.bump_generation(GenerationKey::Library);
let profile_id = active_profile_id(&engine.state, &Value::Null);
let command = ToggleWatchlistCommand { kind: "toggleWatchlist", item };
let command = ToggleWatchlistCommand {
kind: "toggleWatchlist",
item,
};
let command_value = serde_json::to_value(&command).unwrap_or(Value::Null);
engine.state.library.last_command = command_value.clone();
vec![engine.effect(
EffectKind::WriteLibraryCommand,
generation,
WriteLibraryCommandPayload { profile_id, command: command_value },
WriteLibraryCommandPayload {
profile_id,
command: command_value,
},
)]
}
pub(super) fn dispatch_toggle_status(engine: &mut HeadlessEngine, list: String, item: Value) -> Vec<EffectEnvelope> {
pub(super) fn dispatch_toggle_status(
engine: &mut HeadlessEngine,
list: String,
item: Value,
) -> Vec<EffectEnvelope> {
let generation = engine.bump_generation(GenerationKey::Library);
let profile_id = active_profile_id(&engine.state, &Value::Null);
let command = ToggleLibraryStatusCommand { kind: "toggleLibraryStatus", list, item };
let command = ToggleLibraryStatusCommand {
kind: "toggleLibraryStatus",
list,
item,
};
let command_value = serde_json::to_value(&command).unwrap_or(Value::Null);
engine.state.library.last_command = command_value.clone();
vec![engine.effect(
EffectKind::WriteLibraryCommand,
generation,
WriteLibraryCommandPayload { profile_id, command: command_value },
WriteLibraryCommandPayload {
profile_id,
command: command_value,
},
)]
}
pub(super) fn dispatch_set_feedback(engine: &mut HeadlessEngine, id: String, value: Option<bool>, meta: Value) -> Vec<EffectEnvelope> {
pub(super) fn dispatch_set_feedback(
engine: &mut HeadlessEngine,
id: String,
value: Option<bool>,
meta: Value,
) -> Vec<EffectEnvelope> {
let generation = engine.bump_generation(GenerationKey::Library);
vec![engine.effect(EffectKind::WriteFeedback, generation, WriteFeedbackPayload { id, value, meta })]
vec![engine.effect(
EffectKind::WriteFeedback,
generation,
WriteFeedbackPayload { id, value, meta },
)]
}
pub(super) fn dispatch_clear_progress(engine: &mut HeadlessEngine, profile: Option<Value>, meta: Value) -> Vec<EffectEnvelope> {
pub(super) fn dispatch_clear_progress(
engine: &mut HeadlessEngine,
profile: Option<Value>,
meta: Value,
) -> Vec<EffectEnvelope> {
let generation = engine.bump_generation(GenerationKey::Library);
vec![engine.effect(
EffectKind::ClearPlaybackProgress,
generation,
ClearPlaybackProgressPayload { profile: profile.unwrap_or(Value::Null), meta },
ClearPlaybackProgressPayload {
profile: profile.unwrap_or(Value::Null),
meta,
},
)]
}
@ -220,7 +265,8 @@ pub(super) fn dispatch_save_progress(
last_audio_language,
last_subtitle_language,
};
engine.state.library.pending_playback_progress = serde_json::to_value(&progress).unwrap_or(Value::Null);
engine.state.library.pending_playback_progress =
serde_json::to_value(&progress).unwrap_or(Value::Null);
vec![engine.effect(
EffectKind::WritePlaybackProgress,
generation,
@ -245,15 +291,15 @@ pub(super) fn dispatch_mark_watched(
let generation = engine.bump_generation(GenerationKey::Library);
let profile_id = active_profile_id(&engine.state, &Value::Null);
let watched_value = watched.unwrap_or(true);
let clean_video_ids: Vec<String> = video_ids.into_iter().filter(|value| !value.trim().is_empty()).fold(
Vec::new(),
|mut acc, value| {
let clean_video_ids: Vec<String> = video_ids
.into_iter()
.filter(|value| !value.trim().is_empty())
.fold(Vec::new(), |mut acc, value| {
if !acc.contains(&value) {
acc.push(value);
}
acc
},
);
});
let command = MarkWatchedCommand {
kind: "markWatched",
series_id,
@ -265,7 +311,10 @@ pub(super) fn dispatch_mark_watched(
let mut effects = vec![engine.effect(
EffectKind::WriteLibraryCommand,
generation,
WriteLibraryCommandPayload { profile_id, command: command_value },
WriteLibraryCommandPayload {
profile_id,
command: command_value,
},
)];
if should_sync_watched_state(profile.as_ref(), meta.as_ref()) {
effects.push(engine.effect(
@ -293,18 +342,36 @@ pub(super) fn complete(
if generation == engine.state.runtime.get(GenerationKey::Library) {
engine.state.library.is_loading = false;
if result.status == "ok" {
engine.state.library.watchlist =
result.value.get("watchlist").cloned().unwrap_or_else(|| serde_json::json!([]));
engine.state.library.continue_watching =
result.value.get("continueWatching").cloned().unwrap_or_else(|| serde_json::json!([]));
engine.state.library.liked =
result.value.get("liked").cloned().unwrap_or_else(|| serde_json::json!([]));
engine.state.library.watched =
result.value.get("watched").cloned().unwrap_or_else(|| serde_json::json!({}));
engine.state.library.dropped =
result.value.get("dropped").cloned().unwrap_or_else(|| serde_json::json!([]));
engine.state.library.completed =
result.value.get("completed").cloned().unwrap_or_else(|| serde_json::json!([]));
engine.state.library.watchlist = result
.value
.get("watchlist")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.library.continue_watching = result
.value
.get("continueWatching")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.library.liked = result
.value
.get("liked")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.library.watched = result
.value
.get("watched")
.cloned()
.unwrap_or_else(|| serde_json::json!({}));
engine.state.library.dropped = result
.value
.get("dropped")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.library.completed = result
.value
.get("completed")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.library.error = Value::Null;
} else {
engine.state.library.error = normalize_error(result.error.clone());
@ -316,10 +383,22 @@ pub(super) fn complete(
if result.status == "ok" {
engine.state.library.last_write = result.value.clone();
engine.state.library.last_write_error = Value::Null;
if let Some(value) = engine.state.library.last_write.get("isInWatchlist").cloned() {
if let Some(value) = engine
.state
.library
.last_write
.get("isInWatchlist")
.cloned()
{
detail::set_is_in_watchlist(engine, value);
}
if let Some(value) = engine.state.library.last_write.get("localWatchedVideoIds").cloned() {
if let Some(value) = engine
.state
.library
.last_write
.get("localWatchedVideoIds")
.cloned()
{
detail::set_local_watched_video_ids(engine, value);
}
} else {
@ -330,7 +409,10 @@ pub(super) fn complete(
"writeFeedback" => {
if generation == engine.state.runtime.get(GenerationKey::Library) {
if result.status == "ok" {
detail::set_feedback(engine, result.value.get("feedback").cloned().unwrap_or(Value::Null));
detail::set_feedback(
engine,
result.value.get("feedback").cloned().unwrap_or(Value::Null),
);
engine.state.library.last_write_error = Value::Null;
} else {
engine.state.library.last_write_error = normalize_error(result.error.clone());
@ -344,7 +426,8 @@ pub(super) fn complete(
engine.state.library.last_write_error = Value::Null;
// Remove the dropped item from home.continueWatching so stale state
// doesn't reappear when the user navigates back to the home screen.
if let Some(dropped_id) = result.value.get("droppedId").and_then(Value::as_str) {
if let Some(dropped_id) = result.value.get("droppedId").and_then(Value::as_str)
{
home::remove_from_continue_watching(engine, dropped_id);
}
} else {
@ -355,7 +438,8 @@ pub(super) fn complete(
"writePlaybackProgress" => {
if generation == engine.state.runtime.get(GenerationKey::Library) {
if result.status == "ok" {
engine.state.library.saved_playback_progress = engine.state.library.pending_playback_progress.clone();
engine.state.library.saved_playback_progress =
engine.state.library.pending_playback_progress.clone();
engine.state.library.pending_playback_progress = Value::Null;
engine.state.library.last_write_error = Value::Null;
} else {
@ -369,7 +453,8 @@ pub(super) fn complete(
engine.state.library.last_watched_sync = result.value.clone();
engine.state.library.last_watched_sync_error = Value::Null;
} else {
engine.state.library.last_watched_sync_error = normalize_error(result.error.clone());
engine.state.library.last_watched_sync_error =
normalize_error(result.error.clone());
}
}
}

View file

@ -63,7 +63,10 @@ static ENGINE_COUNTER: AtomicU64 = AtomicU64::new(1);
static ENGINES: OnceLock<Mutex<HashMap<u64, HeadlessEngine>>> = OnceLock::new();
pub(crate) fn create_headless_engine(initial_json: &str) -> u64 {
let mut engine = HeadlessEngine { next_effect_id: 1, ..HeadlessEngine::default() };
let mut engine = HeadlessEngine {
next_effect_id: 1,
..HeadlessEngine::default()
};
if let Ok(initial_state) = serde_json::from_str::<EngineState>(initial_json) {
engine.state = initial_state;
}
@ -126,7 +129,15 @@ impl HeadlessEngine {
source_addon_transport_url,
source_addon_catalog_type,
profile,
} => detail::dispatch_load(self, content_type, id, language, source_addon_transport_url, source_addon_catalog_type, profile),
} => detail::dispatch_load(
self,
content_type,
id,
language,
source_addon_transport_url,
source_addon_catalog_type,
profile,
),
AppAction::DetailLocalStateRequested {
primary_id,
fallback_id,
@ -303,6 +314,9 @@ impl HeadlessEngine {
language,
force,
} => home::dispatch_load(self, profile, language, force),
AppAction::RefreshContinueWatchingRequested { profile, language } => {
home::dispatch_refresh_continue_watching(self, profile, language)
}
AppAction::LibraryHydrateRequested { profile_id } => {
library::dispatch_hydrate(self, profile_id)
}
@ -508,7 +522,9 @@ impl HeadlessEngine {
let Some(kind) = EffectKind::from_str(&effect.kind) else {
return vec![];
};
self.state.pending_effects.retain(|pending| pending.id != result.effect_id);
self.state
.pending_effects
.retain(|pending| pending.id != result.effect_id);
self.delivered_effect_ids.remove(&result.effect_id);
self.effect_created_at.remove(&result.effect_id);
let effect_type = kind.as_str();
@ -522,7 +538,9 @@ impl HeadlessEngine {
| EffectKind::PrefetchDetailStreams
| EffectKind::FetchDetailStreams
| EffectKind::FetchMetaDetailLookup
| EffectKind::FetchSeasonEpisodes => detail::complete(self, effect_type, generation, &result),
| EffectKind::FetchSeasonEpisodes => {
detail::complete(self, effect_type, generation, &result)
}
EffectKind::LoadStreams
| EffectKind::StartTorrentStream
@ -531,22 +549,31 @@ impl HeadlessEngine {
| EffectKind::FetchIntroSegments
| EffectKind::ResolveIntroImdbId
| EffectKind::FetchSubtitles
| EffectKind::PrefetchNextEpisodeStreams => player::complete(self, effect_type, generation, &result),
| EffectKind::PrefetchNextEpisodeStreams => {
player::complete(self, effect_type, generation, &result)
}
EffectKind::ReadHomeBootstrap
| EffectKind::RefreshContinueWatching
| EffectKind::PrepareDirectPlayback
| EffectKind::FetchCatalogPage => home::complete(self, effect_type, generation, &result),
| EffectKind::FetchCatalogPage => {
home::complete(self, effect_type, generation, &result)
}
EffectKind::ReadLibraryState
| EffectKind::WriteLibraryCommand
| EffectKind::WriteFeedback
| EffectKind::ClearPlaybackProgress
| EffectKind::WritePlaybackProgress
| EffectKind::SyncWatchedState => library::complete(self, effect_type, generation, &result),
| EffectKind::SyncWatchedState => {
library::complete(self, effect_type, generation, &result)
}
EffectKind::FetchAddonManifest
| EffectKind::RefreshInstalledAddons
| EffectKind::FetchAddonResource => addons::complete(self, effect_type, generation, &result),
| EffectKind::FetchAddonResource => {
addons::complete(self, effect_type, generation, &result)
}
EffectKind::RunSearch => search::complete(self, generation, &result),
@ -564,7 +591,9 @@ impl HeadlessEngine {
sync::complete(self, effect_type, generation, &result)
}
EffectKind::RunAuthFlow | EffectKind::ExchangeAuthCode | EffectKind::RefreshAuthToken => {
EffectKind::RunAuthFlow
| EffectKind::ExchangeAuthCode
| EffectKind::RefreshAuthToken => {
auth::complete(self, effect_type, generation, &result)
}
@ -574,7 +603,12 @@ impl HeadlessEngine {
}
}
fn effect<P: serde::Serialize>(&mut self, kind: EffectKind, generation: u64, payload: P) -> EffectEnvelope {
fn effect<P: serde::Serialize>(
&mut self,
kind: EffectKind,
generation: u64,
payload: P,
) -> EffectEnvelope {
let payload = serde_json::to_value(&payload).unwrap_or(Value::Null);
self.effect_raw(kind.as_str(), generation, payload)
}
@ -649,7 +683,11 @@ impl HeadlessEngine {
// over a second, and every other Tauri command shares one global engine mutex — holding
// it for that long would stall unrelated IPC calls behind it. Callers clone what they
// need and drop the lock before calling this.
fn result_patch_json(before: &EngineState, after: &EngineState, effects: Vec<EffectEnvelope>) -> Option<String> {
fn result_patch_json(
before: &EngineState,
after: &EngineState,
effects: Vec<EffectEnvelope>,
) -> Option<String> {
serde_json::to_string(&DispatchResult {
state: StatePatch::diff(before, after),
effects,
@ -667,7 +705,9 @@ fn engines() -> &'static Mutex<HashMap<u64, HeadlessEngine>> {
// Recovering the guard accepts that one engine's state might be left
// mid-update, which is still far better than every other handle going dark.
fn lock_engines() -> std::sync::MutexGuard<'static, HashMap<u64, HeadlessEngine>> {
engines().lock().unwrap_or_else(|poisoned| poisoned.into_inner())
engines()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[cfg(test)]
@ -1118,7 +1158,13 @@ mod tests {
.unwrap(),
)
.unwrap();
assert_eq!(home_loaded["state"]["home"]["continueWatching"].as_array().unwrap().len(), 2);
assert_eq!(
home_loaded["state"]["home"]["continueWatching"]
.as_array()
.unwrap()
.len(),
2
);
let requested: Value = serde_json::from_str(
&headless_engine_dispatch_json(
@ -1144,7 +1190,9 @@ mod tests {
)
.unwrap();
let continue_watching = completed["state"]["home"]["continueWatching"].as_array().unwrap();
let continue_watching = completed["state"]["home"]["continueWatching"]
.as_array()
.unwrap();
assert_eq!(continue_watching.len(), 1);
assert_eq!(continue_watching[0]["id"], "tt2");
assert!(destroy_headless_engine(handle));
@ -1454,9 +1502,18 @@ mod tests {
)
.unwrap();
assert_eq!(prefetch_requested["effects"][0]["type"], "prefetchNextEpisodeStreams");
assert_eq!(prefetch_requested["effects"][0]["payload"]["nextVideoId"], "tt1:1:2");
assert_eq!(prefetch_requested["state"]["player"]["prefetchingNextVideoId"], "tt1:1:2");
assert_eq!(
prefetch_requested["effects"][0]["type"],
"prefetchNextEpisodeStreams"
);
assert_eq!(
prefetch_requested["effects"][0]["payload"]["nextVideoId"],
"tt1:1:2"
);
assert_eq!(
prefetch_requested["state"]["player"]["prefetchingNextVideoId"],
"tt1:1:2"
);
// Duplicate card-shown dispatch must not change prefetching state.
let duplicate: Value = serde_json::from_str(
@ -1491,8 +1548,14 @@ mod tests {
)
.unwrap();
assert_eq!(prefetch_done["state"]["player"]["prefetchedNextEpisode"]["videoId"], "tt1:1:2");
assert_eq!(prefetch_done["state"]["player"]["prefetchedNextEpisode"]["streams"][0]["title"], "S");
assert_eq!(
prefetch_done["state"]["player"]["prefetchedNextEpisode"]["videoId"],
"tt1:1:2"
);
assert_eq!(
prefetch_done["state"]["player"]["prefetchedNextEpisode"]["streams"][0]["title"],
"S"
);
assert!(prefetch_done["state"]["player"]["prefetchingNextVideoId"].is_null());
// 3. User navigates to ep2 — load streams without passing initial_streams.

View file

@ -19,7 +19,11 @@ impl Default for NavigationState {
}
}
pub(super) fn dispatch(engine: &mut HeadlessEngine, route: String, params: Option<Value>) -> Vec<EffectEnvelope> {
pub(super) fn dispatch(
engine: &mut HeadlessEngine,
route: String,
params: Option<Value>,
) -> Vec<EffectEnvelope> {
engine.state.navigation = NavigationState {
route,
params: params.unwrap_or(Value::Null),

View file

@ -25,6 +25,7 @@ struct EnqueueOfflineDownloadPayload {
language: Option<String>,
}
#[allow(clippy::too_many_arguments)]
pub(super) fn dispatch(
engine: &mut HeadlessEngine,
meta: Value,
@ -49,7 +50,11 @@ pub(super) fn dispatch(
vec![engine.effect(EffectKind::EnqueueOfflineDownload, generation, payload)]
}
pub(super) fn complete(engine: &mut HeadlessEngine, generation: u64, result: &EffectResultInput) -> Vec<EffectEnvelope> {
pub(super) fn complete(
engine: &mut HeadlessEngine,
generation: u64,
result: &EffectResultInput,
) -> Vec<EffectEnvelope> {
if generation == engine.state.runtime.get(GenerationKey::Offline) {
if result.status == "ok" {
engine.state.offline.last_enqueued = result.value.clone();

View file

@ -83,15 +83,27 @@ impl PlayerState {
// behavior of overwriting `engine.state["player"]` with the flow's state outright.
fn from_flow_state(flow_state: PlayerFlowState) -> Self {
Self {
current_video_id: flow_state.current_video_id.map(Value::String).unwrap_or(Value::Null),
current_video_id: flow_state
.current_video_id
.map(Value::String)
.unwrap_or(Value::Null),
current_streams: Value::Array(flow_state.current_streams),
current_stream_index: flow_state.current_stream_index as i64,
current_url: flow_state.current_url.map(Value::String).unwrap_or(Value::Null),
current_url: flow_state
.current_url
.map(Value::String)
.unwrap_or(Value::Null),
zero_speed_ticks: flow_state.zero_speed_ticks as i64,
is_buffering: flow_state.is_buffering,
is_video_rendered: flow_state.is_video_rendered,
player_error: flow_state.player_error.map(Value::String).unwrap_or(Value::Null),
preferred_binge_group: flow_state.preferred_binge_group.map(Value::String).unwrap_or(Value::Null),
player_error: flow_state
.player_error
.map(Value::String)
.unwrap_or(Value::Null),
preferred_binge_group: flow_state
.preferred_binge_group
.map(Value::String)
.unwrap_or(Value::Null),
..Self::default()
}
}
@ -207,8 +219,15 @@ pub(super) fn dispatch_next_episode_prefetch(
language: Option<String>,
profile: Option<Value>,
) -> Vec<EffectEnvelope> {
let already_prefetching = engine.state.player.prefetching_next_video_id.as_str().is_some_and(|v| v == next_video_id);
let already_cached = engine.state.player.prefetched_next_episode["videoId"].as_str().is_some_and(|v| v == next_video_id);
let already_prefetching = engine
.state
.player
.prefetching_next_video_id
.as_str()
.is_some_and(|v| v == next_video_id);
let already_cached = engine.state.player.prefetched_next_episode["videoId"]
.as_str()
.is_some_and(|v| v == next_video_id);
if already_prefetching || already_cached {
return vec![];
}
@ -263,7 +282,10 @@ pub(super) fn dispatch_load_streams(
let prefetched = engine.state.player.prefetched_next_episode.clone();
let cached_video_id = prefetched["videoId"].as_str().map(str::to_string);
if cached_video_id.is_some() && cached_video_id == current_video_id {
initial_streams = prefetched["streams"].as_array().cloned().unwrap_or_default();
initial_streams = prefetched["streams"]
.as_array()
.cloned()
.unwrap_or_default();
effective_initial_video_id = cached_video_id;
engine.state.player.prefetched_next_episode = Value::Null;
}
@ -303,12 +325,22 @@ pub(super) fn dispatch_load_streams(
.into_iter()
.map(|effect| {
let mut payload = serde_json::to_value(&effect).unwrap_or(Value::Null);
let kind = payload.get("type").and_then(Value::as_str).unwrap_or("unknown").to_string();
let kind = payload
.get("type")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
if kind == "loadStreams" {
if let Value::Object(map) = &mut payload {
map.insert("initialStreams".to_string(), pending_value["initialStreams"].clone());
map.insert(
"initialStreams".to_string(),
pending_value["initialStreams"].clone(),
);
map.insert("title".to_string(), pending_value["title"].clone());
map.insert("originalName".to_string(), pending_value["originalName"].clone());
map.insert(
"originalName".to_string(),
pending_value["originalName"].clone(),
);
map.insert("year".to_string(), pending_value["year"].clone());
map.insert("language".to_string(), pending_value["language"].clone());
map.insert("profile".to_string(), pending_value["profile"].clone());
@ -319,6 +351,7 @@ pub(super) fn dispatch_load_streams(
.collect()
}
#[allow(clippy::too_many_arguments)]
pub(super) fn dispatch_streams_loaded(
engine: &mut HeadlessEngine,
streams: Vec<Value>,
@ -348,8 +381,13 @@ pub(super) fn dispatch_streams_loaded(
vec![]
}
pub(super) fn dispatch_streams_failed(engine: &mut HeadlessEngine, err_code: Option<String>) -> Vec<EffectEnvelope> {
let action = PlayerFlowAction::StreamsFailed { error_code: err_code };
pub(super) fn dispatch_streams_failed(
engine: &mut HeadlessEngine,
err_code: Option<String>,
) -> Vec<EffectEnvelope> {
let action = PlayerFlowAction::StreamsFailed {
error_code: err_code,
};
let mut flow_state = engine.state.player.to_flow_state();
let _ = player_flow::dispatch(&mut flow_state, action);
engine.state.player = PlayerState::from_flow_state(flow_state);
@ -372,8 +410,13 @@ pub(super) fn dispatch_resolve_playback(
if stream_policy::is_torrent_playback_url(&url) {
let stream_value = stream.unwrap_or(Value::Null);
let file_idx = stream_value["fileIdx"].as_i64();
let preferred_filename = stream_value["effectiveFilename"].as_str().map(ToString::to_string);
let sources = stream_value["sources"].as_array().cloned().unwrap_or_default();
let preferred_filename = stream_value["effectiveFilename"]
.as_str()
.map(ToString::to_string);
let sources = stream_value["sources"]
.as_array()
.cloned()
.unwrap_or_default();
vec![engine.effect(
EffectKind::StartTorrentStream,
generation,
@ -390,7 +433,13 @@ pub(super) fn dispatch_resolve_playback(
} else {
engine.state.player.resolved_url = engine.state.player.current_url.clone();
engine.state.player.is_buffering = false;
vec![engine.effect(EffectKind::StopTorrent, generation, StopTorrentPayload { reason: "directPlayback" })]
vec![engine.effect(
EffectKind::StopTorrent,
generation,
StopTorrentPayload {
reason: "directPlayback",
},
)]
}
}
@ -431,16 +480,32 @@ pub(super) fn dispatch_intro_segments(
vec![engine.effect(
EffectKind::FetchIntroSegments,
generation,
FetchIntroSegmentsPayload { imdb_id, season, episode, title, use_intro_db, use_ani_skip },
FetchIntroSegmentsPayload {
imdb_id,
season,
episode,
title,
use_intro_db,
use_ani_skip,
},
)]
}
pub(super) fn dispatch_intro_imdb_id(engine: &mut HeadlessEngine, meta: Value, video_id: Option<String>, language: Option<String>) -> Vec<EffectEnvelope> {
pub(super) fn dispatch_intro_imdb_id(
engine: &mut HeadlessEngine,
meta: Value,
video_id: Option<String>,
language: Option<String>,
) -> Vec<EffectEnvelope> {
let generation = engine.bump_generation(GenerationKey::Intro);
vec![engine.effect(
EffectKind::ResolveIntroImdbId,
generation,
ResolveIntroImdbIdPayload { meta, video_id, language: language.unwrap_or_else(|| "en".to_string()) },
ResolveIntroImdbIdPayload {
meta,
video_id,
language: language.unwrap_or_else(|| "en".to_string()),
},
)]
}
@ -456,7 +521,12 @@ pub(super) fn dispatch_subtitle_load(
vec![engine.effect(
EffectKind::FetchSubtitles,
generation,
FetchSubtitlesPayload { stream, content_type, id, extra_args: extra_args.unwrap_or_default() },
FetchSubtitlesPayload {
stream,
content_type,
id,
extra_args: extra_args.unwrap_or_default(),
},
)]
}
@ -478,9 +548,13 @@ pub(super) fn complete(
Some(pending["initialStreamIndex"].as_i64().unwrap_or(0) as i32),
pending["savedUrl"].as_str().map(ToString::to_string),
pending["savedTitle"].as_str().map(ToString::to_string),
pending["sourceSelectionMode"].as_str().map(ToString::to_string),
pending["sourceSelectionMode"]
.as_str()
.map(ToString::to_string),
pending["regexPattern"].as_str().map(ToString::to_string),
pending["preferredBingeGroup"].as_str().map(ToString::to_string),
pending["preferredBingeGroup"]
.as_str()
.map(ToString::to_string),
);
} else {
dispatch_streams_failed(engine, Some(error_code(&result.error)));
@ -491,7 +565,8 @@ pub(super) fn complete(
"startTorrentStream" => {
if generation == engine.state.runtime.get(GenerationKey::Player) {
if result.status == "ok" {
engine.state.player.resolved_url = result.value.get("url").cloned().unwrap_or(Value::Null);
engine.state.player.resolved_url =
result.value.get("url").cloned().unwrap_or(Value::Null);
engine.state.player.is_buffering = false;
engine.state.player.player_error = Value::Null;
} else {
@ -512,7 +587,9 @@ pub(super) fn complete(
}
}
"stopTorrent" => {
if generation == engine.state.runtime.get(GenerationKey::Player) && result.status != "ok" {
if generation == engine.state.runtime.get(GenerationKey::Player)
&& result.status != "ok"
{
engine.state.player.stop_torrent_warning = normalize_error(result.error.clone());
}
}
@ -542,8 +619,11 @@ pub(super) fn complete(
if generation == engine.state.runtime.get(GenerationKey::Player) {
engine.state.player.subtitle_loading = false;
if result.status == "ok" {
engine.state.player.subtitles =
result.value.get("subtitles").cloned().unwrap_or_else(|| result.value.clone());
engine.state.player.subtitles = result
.value
.get("subtitles")
.cloned()
.unwrap_or_else(|| result.value.clone());
engine.state.player.player_error = Value::Null;
} else {
engine.state.player.player_error = Value::String(error_code(&result.error));

View file

@ -56,7 +56,11 @@ pub(super) fn dispatch(
)]
}
pub(super) fn complete(engine: &mut HeadlessEngine, generation: u64, result: &EffectResultInput) -> Vec<EffectEnvelope> {
pub(super) fn complete(
engine: &mut HeadlessEngine,
generation: u64,
result: &EffectResultInput,
) -> Vec<EffectEnvelope> {
if generation == engine.state.runtime.get(GenerationKey::Search) {
engine.state.search.is_loading = false;
if result.status == "ok" {
@ -70,7 +74,8 @@ pub(super) fn complete(engine: &mut HeadlessEngine, generation: u64, result: &Ef
.get("categories")
.cloned()
.unwrap_or_else(|| serde_json::json!([]));
engine.state.search.grouping = result.value.get("grouping").cloned().unwrap_or(Value::Null);
engine.state.search.grouping =
result.value.get("grouping").cloned().unwrap_or(Value::Null);
engine.state.search.error = Value::Null;
} else {
engine.state.search.error = normalize_error(result.error.clone());

View file

@ -21,16 +21,28 @@ struct WriteSettingsPayload {
value: Value,
}
pub(super) fn dispatch(engine: &mut HeadlessEngine, key: String, value: Value) -> Vec<EffectEnvelope> {
pub(super) fn dispatch(
engine: &mut HeadlessEngine,
key: String,
value: Value,
) -> Vec<EffectEnvelope> {
let generation = engine.bump_generation(GenerationKey::Settings);
if !engine.state.settings.values.is_object() {
engine.state.settings.values = serde_json::json!({});
}
engine.state.settings.values[key.as_str()] = value.clone();
vec![engine.effect(EffectKind::WriteSettings, generation, WriteSettingsPayload { key, value })]
vec![engine.effect(
EffectKind::WriteSettings,
generation,
WriteSettingsPayload { key, value },
)]
}
pub(super) fn complete(engine: &mut HeadlessEngine, generation: u64, result: &EffectResultInput) -> Vec<EffectEnvelope> {
pub(super) fn complete(
engine: &mut HeadlessEngine,
generation: u64,
result: &EffectResultInput,
) -> Vec<EffectEnvelope> {
if generation == engine.state.runtime.get(GenerationKey::Settings) {
if result.status != "ok" {
engine.state.settings.last_write_error = normalize_error(result.error.clone());

View file

@ -109,8 +109,10 @@ pub(super) fn complete(
if generation == engine.state.runtime.get(GenerationKey::Sync) {
engine.state.sync.is_loading = false;
if result.status == "ok" {
let updated_profile = result.value.get("profile").cloned().unwrap_or(Value::Null);
engine.state.sync.snapshot = result.value.get("snapshot").cloned().unwrap_or(Value::Null);
let updated_profile =
result.value.get("profile").cloned().unwrap_or(Value::Null);
engine.state.sync.snapshot =
result.value.get("snapshot").cloned().unwrap_or(Value::Null);
if !updated_profile.is_null() {
profile::update_active(engine, updated_profile);
}

View file

@ -420,7 +420,7 @@ pub(crate) fn optimize_home_rows_json(request_json: &str) -> Option<String> {
output.extend(kept);
output.extend(fallback);
let limit = 24 + output_pinned_count(&output);
let output = distinct_categories(output.into_iter())
let output = distinct_categories(output)
.into_iter()
.take(limit)
.collect::<Vec<_>>();
@ -686,7 +686,7 @@ pub(crate) fn build_billboard_pool_json(
.collect();
let mut editorial = editorial_raw;
editorial.sort_by(|a, b| score_candidate(b, None).cmp(&score_candidate(a, None)));
editorial.sort_by_key(|item| std::cmp::Reverse(score_candidate(item, None)));
let editorial: Vec<Value> = distinct_by_title_key(editorial)
.into_iter()
.take(3)
@ -725,10 +725,8 @@ pub(crate) fn build_billboard_pool_json(
let final_pool: Vec<Value> = if preferred.len() >= 10 {
preferred.into_iter().take(10).collect()
} else {
let preferred_keys: HashSet<String> =
preferred.iter().map(billboard_key_value).collect();
let preferred_titles: HashSet<String> =
preferred.iter().map(title_key_value).collect();
let preferred_keys: HashSet<String> = preferred.iter().map(billboard_key_value).collect();
let preferred_titles: HashSet<String> = preferred.iter().map(title_key_value).collect();
let extras = ranked.into_iter().filter(|m| {
!preferred_keys.contains(&billboard_key_value(m))
&& !preferred_titles.contains(&title_key_value(m))
@ -763,8 +761,8 @@ pub(crate) fn normalize_home_catalog_items_json(
today_iso: &str,
) -> Option<String> {
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
let assign_rank = genre.map(|g| g.is_empty()).unwrap_or(true)
&& RANKED_CATALOG_IDS.contains(&catalog_id);
let assign_rank =
genre.map(|g| g.is_empty()).unwrap_or(true) && RANKED_CATALOG_IDS.contains(&catalog_id);
let mut rank: i64 = 0;
let result: Vec<Value> = items
@ -787,12 +785,19 @@ 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> {
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 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();
@ -803,10 +808,18 @@ pub(crate) fn build_home_collection_shelves_json(profile_json: &str, addons_json
Some(o) => o,
None => continue,
};
if !c.get("showOnHome").and_then(Value::as_bool).unwrap_or(false) {
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(&[]);
let folders = c
.get("folders")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]);
if folders.is_empty() {
continue;
}
@ -818,17 +831,28 @@ pub(crate) fn build_home_collection_shelves_json(profile_json: &str, addons_json
Some(o) => o,
None => continue,
};
let folder_title = folder.get("title").and_then(Value::as_str).unwrap_or("").to_string();
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)
let folder_id = folder
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| format!("col{ci}_f{fi}"));
let resolved = resolve_folder_catalog_sources(folder, addons_json);
if !resolved.is_empty() {
hidden.push(hidden_folder_category(&folder_id, &folder_title, folder, resolved));
hidden.push(hidden_folder_category(
&folder_id,
&folder_title,
folder,
resolved,
));
}
tiles.push(folder_tile(&folder_id, &folder_title, folder));
}
@ -837,7 +861,9 @@ pub(crate) fn build_home_collection_shelves_json(profile_json: &str, addons_json
continue;
}
let shelf_id = c.get("id").and_then(Value::as_str)
let shelf_id = c
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| format!("col{ci}"));
let shelf = json!({
@ -859,7 +885,8 @@ pub(crate) fn build_home_collection_shelves_json(profile_json: &str, addons_json
"pinnedShelves": pinned,
"regularShelves": regular,
"hiddenFolderCategories": hidden,
})).ok()
}))
.ok()
}
// A folder's catalog sources, preferring its explicit catalogSources list and
@ -874,7 +901,8 @@ fn resolve_folder_catalog_sources(folder: &Map<String, Value>, addons_json: &str
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 });
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());
}
@ -887,7 +915,8 @@ fn resolve_folder_catalog_sources(folder: &Map<String, Value>, addons_json: &str
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" });
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());
}
@ -919,11 +948,19 @@ fn hidden_folder_category(
}
fn folder_tile(folder_id: &str, folder_title: &str, folder: &Map<String, Value>) -> Value {
let img_url = folder.get("coverImageUrl").and_then(Value::as_str)
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 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,
@ -993,7 +1030,10 @@ mod tests {
assert_eq!(pinned.len(), 1);
assert_eq!(pinned[0]["id"], "col1");
assert_eq!(pinned[0]["items"][0]["id"], "f1");
assert_eq!(pinned[0]["items"][0]["poster"], "https://img.example/cover.jpg");
assert_eq!(
pinned[0]["items"][0]["poster"],
"https://img.example/cover.jpg"
);
let hidden = result["hiddenFolderCategories"].as_array().unwrap();
assert_eq!(hidden.len(), 1);

View file

@ -23,16 +23,28 @@ fn collect_segments(data: &Value) -> Vec<Value> {
result.push(seg);
}
}
let start = number_from_keys(obj, &[
&format!("{seg_type}Start"), &format!("{seg_type}_start"),
&format!("{seg_type}StartTime"), &format!("{seg_type}_start_time"),
&format!("{seg_type}StartMs"), &format!("{seg_type}_start_ms"),
]);
let end = number_from_keys(obj, &[
&format!("{seg_type}End"), &format!("{seg_type}_end"),
&format!("{seg_type}EndTime"), &format!("{seg_type}_end_time"),
&format!("{seg_type}EndMs"), &format!("{seg_type}_end_ms"),
]);
let start = number_from_keys(
obj,
&[
&format!("{seg_type}Start"),
&format!("{seg_type}_start"),
&format!("{seg_type}StartTime"),
&format!("{seg_type}_start_time"),
&format!("{seg_type}StartMs"),
&format!("{seg_type}_start_ms"),
],
);
let end = number_from_keys(
obj,
&[
&format!("{seg_type}End"),
&format!("{seg_type}_end"),
&format!("{seg_type}EndTime"),
&format!("{seg_type}_end_time"),
&format!("{seg_type}EndMs"),
&format!("{seg_type}_end_ms"),
],
);
if let (Some(s), Some(e)) = (start, end) {
let start_ms = normalize_time(s);
let end_ms = normalize_time(e);
@ -46,36 +58,94 @@ fn collect_segments(data: &Value) -> Vec<Value> {
result.push(seg);
}
}
result.into_iter().filter(|s| {
let st = s.get("startTime").and_then(Value::as_i64).unwrap_or(0);
let et = s.get("endTime").and_then(Value::as_i64).unwrap_or(0);
et > st
}).collect()
result
.into_iter()
.filter(|s| {
let st = s.get("startTime").and_then(Value::as_i64).unwrap_or(0);
let et = s.get("endTime").and_then(Value::as_i64).unwrap_or(0);
et > st
})
.collect()
}
_ => vec![],
}
}
fn segment_from_object(obj: &serde_json::Map<String, Value>) -> Option<Value> {
let start = number_from_keys(obj, &["startTime", "start", "from", "start_sec", "start_time", "startTimeMs", "start_ms", "startOffset"])?;
let end = number_from_keys(obj, &["endTime", "end", "to", "end_sec", "end_time", "endTimeMs", "end_ms", "endOffset"])?;
let raw_type = string_from_keys(obj, &["segment_type", "skip_type", "category", "name", "type"]).unwrap_or_else(|| "intro".to_string());
let start = number_from_keys(
obj,
&[
"startTime",
"start",
"from",
"start_sec",
"start_time",
"startTimeMs",
"start_ms",
"startOffset",
],
)?;
let end = number_from_keys(
obj,
&[
"endTime",
"end",
"to",
"end_sec",
"end_time",
"endTimeMs",
"end_ms",
"endOffset",
],
)?;
let raw_type = string_from_keys(
obj,
&["segment_type", "skip_type", "category", "name", "type"],
)
.unwrap_or_else(|| "intro".to_string());
let seg_type = normalize_skip_type(&raw_type);
let start_ms = normalize_time(start);
let end_ms = normalize_time(end);
if end_ms <= start_ms { return None; }
if end_ms <= start_ms {
return None;
}
Some(make_segment(seg_type, start_ms, end_ms))
}
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", "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 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);
let end_ms = normalize_time(end);
if end_ms <= start_ms { return None; }
if end_ms <= start_ms {
return None;
}
Some(make_segment(seg_type, start_ms, end_ms))
}
@ -86,8 +156,16 @@ fn make_segment(seg_type: &str, start_ms: i64, end_ms: i64) -> Value {
fn number_from_keys(obj: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<f64> {
for key in keys {
match obj.get(*key) {
Some(Value::Number(n)) => if let Some(f) = n.as_f64() { return Some(f); },
Some(Value::String(s)) => if let Ok(f) = s.trim().parse::<f64>() { return Some(f); },
Some(Value::Number(n)) => {
if let Some(f) = n.as_f64() {
return Some(f);
}
}
Some(Value::String(s)) => {
if let Ok(f) = s.trim().parse::<f64>() {
return Some(f);
}
}
_ => {}
}
}
@ -98,14 +176,20 @@ fn string_from_keys(obj: &serde_json::Map<String, Value>, keys: &[&str]) -> Opti
for key in keys {
if let Some(Value::String(s)) = obj.get(*key) {
let t = s.trim();
if !t.is_empty() { return Some(t.to_string()); }
if !t.is_empty() {
return Some(t.to_string());
}
}
}
None
}
fn normalize_time(value: f64) -> i64 {
if value < 10_000.0 { (value * 1000.0).round() as i64 } else { value.round() as i64 }
if value < 10_000.0 {
(value * 1000.0).round() as i64
} else {
value.round() as i64
}
}
pub(crate) fn normalize_skip_type(raw: &str) -> &'static str {
@ -120,30 +204,43 @@ pub(crate) fn normalize_skip_type(raw: &str) -> &'static str {
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)?;
let segments: Vec<Value> = items.iter().filter_map(|item| {
let skip_type = item.get("skipType").and_then(Value::as_str)?;
let interval = item.get("interval")?;
let start = interval.get("startTime").and_then(Value::as_f64)?;
let end = interval.get("endTime").and_then(Value::as_f64)?;
let start_ms = normalize_time(start);
let end_ms = normalize_time(end);
if end_ms <= start_ms { return None; }
Some(make_segment(normalize_skip_type(skip_type), start_ms, end_ms))
}).collect();
let segments: Vec<Value> = items
.iter()
.filter_map(|item| {
let skip_type = item.get("skipType").and_then(Value::as_str)?;
let interval = item.get("interval")?;
let start = interval.get("startTime").and_then(Value::as_f64)?;
let end = interval.get("endTime").and_then(Value::as_f64)?;
let start_ms = normalize_time(start);
let end_ms = normalize_time(end);
if end_ms <= start_ms {
return None;
}
Some(make_segment(
normalize_skip_type(skip_type),
start_ms,
end_ms,
))
})
.collect();
serde_json::to_string(&segments).ok()
}
pub(crate) fn unique_intro_segments_json(segments_a_json: &str, segments_b_json: &str) -> Option<String> {
pub(crate) fn unique_intro_segments_json(
segments_a_json: &str,
segments_b_json: &str,
) -> Option<String> {
let a: Vec<Value> = serde_json::from_str(segments_a_json).unwrap_or_default();
let b: Vec<Value> = serde_json::from_str(segments_b_json).unwrap_or_default();
dedup_and_sort(a.into_iter().chain(b.into_iter()).collect())
dedup_and_sort(a.into_iter().chain(b).collect())
}
pub(crate) fn merge_intro_segments_json(sources_json: &str) -> Option<String> {
let sources: Vec<Value> = serde_json::from_str(sources_json).ok()?;
let all: Vec<Value> = sources.into_iter().flat_map(|s| {
s.as_array().cloned().unwrap_or_default()
}).collect();
let all: Vec<Value> = sources
.into_iter()
.flat_map(|s| s.as_array().cloned().unwrap_or_default())
.collect();
dedup_and_sort(all)
}
@ -159,7 +256,9 @@ fn dedup_and_sort(segments: Vec<Value>) -> Option<String> {
);
let end = seg.get("endTime").and_then(Value::as_i64).unwrap_or(0);
let start = seg.get("startTime").and_then(Value::as_i64).unwrap_or(0);
if end <= start { continue; }
if end <= start {
continue;
}
if seen.insert(key, true).is_none() {
result.push(seg);
}

View file

@ -1,44 +1,86 @@
// Non-native consumers intentionally compile partial API surfaces: desktop uses
// Rust/Tauri calls plus core_invoke, WASM exposes a small JS bridge, and the
// streaming engine uses only stream policy helpers. The Android/native build is
// the exhaustive JNI surface, so keep dead-code checking strict there and avoid
// warning noise for the partial compatibility builds.
#![cfg_attr(not(feature = "native"), allow(dead_code))]
#[cfg(feature = "uniffi-bindings")]
uniffi::setup_scaffolding!();
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod addon_protocol;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod addon_resource;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod addon_store;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod app_state;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod calendar_plan;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod cast_protocol;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod constants;
mod content_identity;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub mod core_api;
#[cfg(not(any(feature = "full-api", not(feature = "streaming-shared"))))]
mod core_api;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub mod core_contract;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod data_policy;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod discovery_plan;
#[cfg(feature = "native")]
mod dolby_vision_rpu;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod external_sync;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod headless_adapter_plan;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod headless_engine;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod home_ranking;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod intro_segments;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod library_state;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod offline_download;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod platform_plan;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod player_flow;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod player_policy;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod player_scrobble;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod profile_contract;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod profile_prefs;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod repository_flow;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod search_plan;
mod stream_policy;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod tmdb_plan;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod watchlist_plan;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub mod env;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub mod ffi;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub mod runtime;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub mod types;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub mod bindings;
pub use core_api::FluxaCore;
@ -47,7 +89,10 @@ pub use core_api::FluxaCore;
// pub(crate) for real consumers — this exists purely so libFuzzer can call
// straight into them without going through ffi::core_invoke's catch_unwind,
// which would otherwise swallow the exact panics fuzzing is trying to find.
#[cfg(feature = "fuzzing")]
#[cfg(all(
feature = "fuzzing",
any(feature = "full-api", not(feature = "streaming-shared"))
))]
pub mod fuzz_targets {
pub use crate::addon_protocol::parse_manifest;
pub use crate::content_identity::{
@ -56,7 +101,7 @@ pub mod fuzz_targets {
};
}
#[cfg(test)]
#[cfg(all(test, any(feature = "full-api", not(feature = "streaming-shared"))))]
mod tests {
use crate::addon_protocol::{
catalog_has_required_extra_except, catalog_requires_extra, catalog_supports_extra,
@ -238,7 +283,10 @@ mod tests {
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
.expect("torrent fallback info");
assert_eq!(fallback.get("selectedFileIdx").and_then(Value::as_i64), Some(2));
assert_eq!(
fallback.get("selectedFileIdx").and_then(Value::as_i64),
Some(2)
);
assert_eq!(
fallback.get("selectedReason").and_then(Value::as_str),
Some("largest-video")

View file

@ -174,7 +174,10 @@ pub(crate) fn filter_home_continue_watching_json(
.iter()
.filter(|item| {
let item_type = item.get("type").and_then(Value::as_str).unwrap_or("");
let last_video_id = item.get("lastVideoId").and_then(Value::as_str).unwrap_or("");
let last_video_id = item
.get("lastVideoId")
.and_then(Value::as_str)
.unwrap_or("");
let time_offset = item.get("timeOffset").and_then(Value::as_i64).unwrap_or(0);
let duration = item.get("duration").and_then(Value::as_i64).unwrap_or(0);
let is_series = matches!(item_type, "series" | "tv" | "anime");
@ -185,10 +188,11 @@ pub(crate) fn filter_home_continue_watching_json(
return false;
}
let watched_keys = crate::content_identity::content_watched_keys_value(item);
if item_type == "movie" && !movie_keys.is_empty() {
if watched_keys.iter().any(|k| movie_keys.contains(k.as_str())) {
return false;
}
if item_type == "movie"
&& !movie_keys.is_empty()
&& watched_keys.iter().any(|k| movie_keys.contains(k.as_str()))
{
return false;
}
if is_series && !episode_keys.is_empty() && !last_video_id.is_empty() {
if let Some((_, season, episode)) =
@ -234,13 +238,25 @@ pub(crate) fn normalize_library_document_json(json: &str) -> String {
if !lib.get("history").map(Value::is_array).unwrap_or(false) {
lib.insert("history".to_string(), json!([]));
}
if !lib.get("continueWatching").map(Value::is_array).unwrap_or(false) {
if !lib
.get("continueWatching")
.map(Value::is_array)
.unwrap_or(false)
{
lib.insert("continueWatching".to_string(), json!([]));
}
if !lib.get("progress").map(|v| v.is_object() && !v.is_array()).unwrap_or(false) {
if !lib
.get("progress")
.map(|v| v.is_object() && !v.is_array())
.unwrap_or(false)
{
lib.insert("progress".to_string(), json!({}));
}
if !lib.get("watched").map(|v| v.is_object() && !v.is_array()).unwrap_or(false) {
if !lib
.get("watched")
.map(|v| v.is_object() && !v.is_array())
.unwrap_or(false)
{
lib.insert("watched".to_string(), json!({}));
}
if !lib.get("dropped").map(Value::is_array).unwrap_or(false) {
@ -258,11 +274,16 @@ pub(crate) fn is_up_next_continue_watching_item_json(item_json: &str) -> bool {
}
fn is_up_next_item(item: &Value) -> bool {
let offset = item.get("timeOffset").and_then(Value::as_f64).unwrap_or(0.0);
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);
if duration <= 0.0 { return offset <= 1.0; }
if duration <= 0.0 {
return offset <= 1.0;
}
let progress = offset / duration;
progress < 0.005 || progress >= 0.995
!(0.005..0.995).contains(&progress)
}
pub(crate) fn build_continue_watching_from_progress_json(progress_json: &str) -> Option<String> {
@ -296,6 +317,8 @@ pub(crate) fn build_continue_watching_from_progress_json(progress_json: &str) ->
"lastStreamUrl": entry.get("lastStreamUrl").cloned().unwrap_or(Value::Null),
"lastStreamTitle": entry.get("lastStreamTitle").cloned().unwrap_or(Value::Null),
"lastStream": entry.get("lastStream").cloned().unwrap_or(Value::Null),
"continueWatchingBadge": entry.get("continueWatchingBadge").cloned().unwrap_or(Value::Null),
"continueWatchingEpisodeResolved": entry.get("continueWatchingEpisodeResolved").cloned().unwrap_or(Value::Null),
"savedAt": entry.get("savedAt").cloned().unwrap_or(Value::Null),
}))
})
@ -316,11 +339,17 @@ pub(crate) fn compute_continue_watching_badges_json(
) -> Option<String> {
let mut by_id: std::collections::HashMap<String, Value> = {
let items: Vec<Value> = serde_json::from_str(candidates_json).unwrap_or_default();
items.into_iter().filter_map(|item| {
let id = item.get("id").or_else(|| item.get("_id"))
.and_then(Value::as_str).map(str::to_string)?;
Some((id, item))
}).collect()
items
.into_iter()
.filter_map(|item| {
let id = item
.get("id")
.or_else(|| item.get("_id"))
.and_then(Value::as_str)
.map(str::to_string)?;
Some((id, item))
})
.collect()
};
let videos_by_series: serde_json::Map<String, Value> =
serde_json::from_str(videos_by_series_json).unwrap_or_default();
@ -336,25 +365,43 @@ pub(crate) fn compute_continue_watching_badges_json(
let mut finished_series: Vec<String> = Vec::new();
for (series_id, candidate) in by_id.iter_mut() {
let next = match next_episode_for_candidate(series_id, candidate, &videos_by_series, &cw_list_ids) {
NextEpisodeOutcome::Skip => continue,
NextEpisodeOutcome::MarkFinished => {
finished_series.push(series_id.clone());
continue;
}
NextEpisodeOutcome::Found(next) => next,
};
let next =
match next_episode_for_candidate(series_id, candidate, &videos_by_series, &cw_list_ids)
{
NextEpisodeOutcome::Skip => continue,
NextEpisodeOutcome::MarkFinished => {
finished_series.push(series_id.clone());
continue;
}
NextEpisodeOutcome::Found(next) => next,
};
apply_next_episode_badge(series_id, candidate, &next, now_ms);
}
for id in &finished_series { by_id.remove(id); }
for id in &finished_series {
by_id.remove(id);
}
let mut result: Vec<Value> = by_id.into_values().collect();
result.sort_by(|a, b| {
let a_new = a.get("continueWatchingBadge").and_then(Value::as_str) == Some("newEpisode");
let b_new = b.get("continueWatchingBadge").and_then(Value::as_str) == Some("newEpisode");
if a_new != b_new { return if a_new { std::cmp::Ordering::Less } else { std::cmp::Ordering::Greater }; }
let a_time = a.get("savedAt").or_else(|| a.get("newEpisodeReleasedAt")).and_then(Value::as_str).unwrap_or("");
let b_time = b.get("savedAt").or_else(|| b.get("newEpisodeReleasedAt")).and_then(Value::as_str).unwrap_or("");
if a_new != b_new {
return if a_new {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Greater
};
}
let a_time = a
.get("savedAt")
.or_else(|| a.get("newEpisodeReleasedAt"))
.and_then(Value::as_str)
.unwrap_or("");
let b_time = b
.get("savedAt")
.or_else(|| b.get("newEpisodeReleasedAt"))
.and_then(Value::as_str)
.unwrap_or("");
b_time.cmp(a_time)
});
serde_json::to_string(&result).ok()
@ -368,7 +415,10 @@ fn seed_candidates_from_last_watched(
last_watched: &serde_json::Map<String, Value>,
) {
for (series_id, raw) in last_watched {
let meta = match raw.get("meta") { Some(m) if m.get("type").and_then(Value::as_str) == Some("series") => m, _ => continue };
let meta = match raw.get("meta") {
Some(m) if m.get("type").and_then(Value::as_str) == Some("series") => m,
_ => continue,
};
let record = raw;
by_id.entry(series_id.clone()).or_insert_with(|| json!({
"id": series_id,
@ -427,16 +477,38 @@ fn next_episode_for_candidate(
NextEpisodeOutcome::Skip
};
};
let stored_badge = candidate.get("continueWatchingBadge").and_then(Value::as_str);
let stored_video_id = candidate.get("lastVideoId").and_then(Value::as_str).unwrap_or("").to_string();
let stored_badge = candidate
.get("continueWatchingBadge")
.and_then(Value::as_str);
if stored_badge == Some("upNext")
&& candidate
.get("continueWatchingEpisodeResolved")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return NextEpisodeOutcome::Skip;
}
let stored_video_id = candidate
.get("lastVideoId")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
// When the stored badge is scheduledEpisode, lastEpisodeNumber already points to the
// scheduled episode itself. Re-check that same episode rather than advancing past it.
let next = if stored_badge == Some("scheduledEpisode") {
videos.iter().find(|v| {
let vid = v.get("id").or_else(|| v.get("_id")).and_then(Value::as_str).unwrap_or("");
vid == stored_video_id
}).cloned().or_else(|| first_episode_after(videos, season, episode))
videos
.iter()
.find(|v| {
let vid = v
.get("id")
.or_else(|| v.get("_id"))
.and_then(Value::as_str)
.unwrap_or("");
vid == stored_video_id
})
.cloned()
.or_else(|| first_episode_after(videos, season, episode))
} else {
first_episode_after(videos, season, episode)
};
@ -452,13 +524,30 @@ fn next_episode_for_candidate(
// Computes the badge (upNext / newEpisode / scheduledEpisode) for advancing `candidate`
// to `next`, and rewrites `candidate` in place to point at that episode.
fn apply_next_episode_badge(series_id: &str, candidate: &mut Value, next: &Value, now_ms: i64) {
let existing_video_id = candidate.get("lastVideoId").and_then(Value::as_str).unwrap_or("").to_string();
let next_id = next.get("id").or_else(|| next.get("_id")).and_then(Value::as_str)
.unwrap_or(&existing_video_id).to_string();
if !is_up_next_item(candidate) && existing_video_id != next_id { return; }
let existing_video_id = candidate
.get("lastVideoId")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let next_id = next
.get("id")
.or_else(|| next.get("_id"))
.and_then(Value::as_str)
.unwrap_or(&existing_video_id)
.to_string();
if !is_up_next_item(candidate) && existing_video_id != next_id {
return;
}
let is_new_target = existing_video_id != next_id;
let is_released = is_episode_released(next, now_ms);
let existing_badge = if !is_new_target { candidate.get("continueWatchingBadge").and_then(Value::as_str).map(str::to_string) } else { None };
let existing_badge = if !is_new_target {
candidate
.get("continueWatchingBadge")
.and_then(Value::as_str)
.map(str::to_string)
} else {
None
};
let badge = if !is_released {
"scheduledEpisode"
@ -467,17 +556,31 @@ fn apply_next_episode_badge(series_id: &str, candidate: &mut Value, next: &Value
} else if let Some(b) = existing_badge.as_deref() {
b
} else {
let watched_at = candidate.get("savedAt").and_then(Value::as_str)
let watched_at = candidate
.get("savedAt")
.and_then(Value::as_str)
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.timestamp_millis()).unwrap_or(now_ms);
let next_released_at = next.get("released").and_then(Value::as_str)
.map(|dt| dt.timestamp_millis())
.unwrap_or(now_ms);
let next_released_at = next
.get("released")
.and_then(Value::as_str)
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.timestamp_millis()).unwrap_or(0);
let was_released_when_watched = next.get("released").is_none() || next_released_at <= watched_at;
if was_released_when_watched { "upNext" } else { "newEpisode" }
}.to_string();
.map(|dt| dt.timestamp_millis())
.unwrap_or(0);
let was_released_when_watched =
next.get("released").is_none() || next_released_at <= watched_at;
if was_released_when_watched {
"upNext"
} else {
"newEpisode"
}
}
.to_string();
let released_str = next.get("released").and_then(Value::as_str)
let released_str = next
.get("released")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
let saved_at_new = if is_new_target && badge == "newEpisode" {
@ -512,17 +615,34 @@ fn apply_next_episode_badge(series_id: &str, candidate: &mut Value, next: &Value
}
fn first_episode_after(videos: &[Value], season: i64, episode: i64) -> Option<Value> {
let mut candidates: Vec<&Value> = videos.iter().filter(|v| {
let vs = v.get("season").and_then(Value::as_i64).unwrap_or(0);
let ve = v.get("episode").or_else(|| v.get("number")).and_then(Value::as_i64).unwrap_or(0);
vs > season || (vs == season && ve > episode)
}).collect();
let mut candidates: Vec<&Value> = videos
.iter()
.filter(|v| {
let vs = v.get("season").and_then(Value::as_i64).unwrap_or(0);
let ve = v
.get("episode")
.or_else(|| v.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
vs > season || (vs == season && ve > episode)
})
.collect();
candidates.sort_by(|a, b| {
let as_ = a.get("season").and_then(Value::as_i64).unwrap_or(0);
let bs = b.get("season").and_then(Value::as_i64).unwrap_or(0);
if as_ != bs { return as_.cmp(&bs); }
let ae = a.get("episode").or_else(|| a.get("number")).and_then(Value::as_i64).unwrap_or(0);
let be = b.get("episode").or_else(|| b.get("number")).and_then(Value::as_i64).unwrap_or(0);
if as_ != bs {
return as_.cmp(&bs);
}
let ae = a
.get("episode")
.or_else(|| a.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
let be = b
.get("episode")
.or_else(|| b.get("number"))
.and_then(Value::as_i64)
.unwrap_or(0);
ae.cmp(&be)
});
candidates.first().map(|v| (*v).clone())
@ -541,20 +661,40 @@ pub(crate) fn is_episode_released(video: &Value, now_ms: i64) -> bool {
/// Given a library JSON and a set of just-watched video IDs, update `lastWatchedEpisodes`.
/// Returns the updated library as JSON.
pub(crate) fn remember_last_watched_episodes_json(lib_json: &str, watched_ids_json: &str) -> String {
pub(crate) fn remember_last_watched_episodes_json(
lib_json: &str,
watched_ids_json: &str,
) -> String {
let mut lib: Value = serde_json::from_str(lib_json).unwrap_or(json!({}));
let watched_ids: std::collections::HashSet<String> = serde_json::from_str(watched_ids_json)
.ok()
.and_then(|v: Value| v.as_array().map(|arr| {
arr.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()
}))
.and_then(|v: Value| {
v.as_array().map(|arr| {
arr.iter()
.filter_map(|s| s.as_str().map(str::to_string))
.collect()
})
})
.unwrap_or_default();
let progress = lib
.get("progress")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let mut last_watched = lib
.get("lastWatchedEpisodes")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let progress = lib.get("progress").and_then(Value::as_object).cloned().unwrap_or_default();
let mut last_watched = lib.get("lastWatchedEpisodes").and_then(Value::as_object).cloned().unwrap_or_default();
for (series_id, raw) in &progress {
let video_id = raw.get("lastVideoId").and_then(Value::as_str).unwrap_or("");
if video_id.is_empty() || !watched_ids.contains(video_id) { continue; }
let meta = match raw.get("meta") { Some(m) if m.get("type").and_then(Value::as_str) == Some("series") => m, _ => continue };
if video_id.is_empty() || !watched_ids.contains(video_id) {
continue;
}
let meta = match raw.get("meta") {
Some(m) if m.get("type").and_then(Value::as_str) == Some("series") => m,
_ => continue,
};
last_watched.insert(series_id.clone(), json!({
"meta": meta,
"lastVideoId": video_id,
@ -566,7 +706,10 @@ pub(crate) fn remember_last_watched_episodes_json(lib_json: &str, watched_ids_js
}));
}
if let Some(obj) = lib.as_object_mut() {
obj.insert("lastWatchedEpisodes".to_string(), Value::Object(last_watched));
obj.insert(
"lastWatchedEpisodes".to_string(),
Value::Object(last_watched),
);
}
serde_json::to_string(&lib).unwrap_or_else(|_| lib_json.to_string())
}
@ -615,8 +758,12 @@ pub(crate) fn format_episode_line_json(
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); }
if season.is_none() {
season = Some(s);
}
if episode.is_none() {
episode = Some(e);
}
}
}
}
@ -659,10 +806,13 @@ pub(crate) fn select_continue_watching_artwork_json(
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/")
(poster.as_deref() != Some(bg)) && !bg.to_lowercase().contains("/poster/")
});
let existing_backdrop = if is_real_backdrop { background.clone() } else { None };
let existing_backdrop = if is_real_backdrop {
background.clone()
} else {
None
};
let result = if !is_horizontal {
thumbnail
@ -701,8 +851,16 @@ pub(crate) fn continue_watching_card_fields_json(
let fields: Vec<Value> = items
.iter()
.map(|item| {
let id = item.get("id").and_then(Value::as_str).unwrap_or("").to_string();
let artwork = select_continue_watching_artwork_json(&item.to_string(), artwork_preference, is_horizontal);
let id = item
.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let artwork = select_continue_watching_artwork_json(
&item.to_string(),
artwork_preference,
is_horizontal,
);
let episode_line = format_episode_line_json(
item.get("lastEpisodeName").and_then(Value::as_str),
item.get("lastEpisodeSeason").and_then(Value::as_i64),
@ -792,9 +950,54 @@ mod tests {
.expect("badges");
let result = result.as_array().unwrap();
assert_eq!(result.len(), 1, "s3 (no video data, not from a real CW list) should be dropped");
assert_eq!(
result.len(),
1,
"s3 (no video data, not from a real CW list) should be dropped"
);
assert_eq!(result[0]["id"], "s1");
assert_eq!(result[0]["lastVideoId"], "s1:1:3");
assert_eq!(result[0]["continueWatchingBadge"], "upNext");
}
#[test]
fn continue_watching_badges_do_not_double_advance_resolved_up_next_entries() {
let candidates = json!([{
"id": "s1",
"_id": "s1",
"type": "series",
"lastVideoId": "s1:2:3",
"lastEpisodeSeason": 2,
"lastEpisodeNumber": 3,
"timeOffset": 1,
"duration": 99999,
"continueWatchingBadge": "upNext",
"continueWatchingEpisodeResolved": true,
"savedAt": "2020-02-01T00:00:00Z",
}]);
let videos_by_series = json!({
"s1": [
{ "id": "s1:2:3", "season": 2, "episode": 3, "released": "2020-01-01T00:00:00Z" },
{ "id": "s1:2:4", "season": 2, "episode": 4, "released": "2020-01-08T00:00:00Z" },
],
});
let now_ms = chrono::DateTime::parse_from_rfc3339("2021-01-01T00:00:00Z")
.unwrap()
.timestamp_millis();
let result = compute_continue_watching_badges_json(
&candidates.to_string(),
&videos_by_series.to_string(),
"{}",
now_ms,
)
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
.expect("badges");
let result = result.as_array().unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0]["lastVideoId"], "s1:2:3");
assert_eq!(result[0]["lastEpisodeNumber"], 3);
assert_eq!(result[0]["continueWatchingBadge"], "upNext");
}
}

View file

@ -122,13 +122,19 @@ pub(crate) fn resource_fetch_plan_json(request_json: &str) -> Option<String> {
"search" => {
let query = request.query.as_deref().unwrap_or("");
for addon in &request.addons {
let Some(transport_url) = addon_transport_url(addon) else { continue };
let Some(transport_url) = addon_transport_url(addon) else {
continue;
};
for catalog in addon_catalogs(addon) {
if !catalog_supports_search(&catalog) {
continue;
}
let Some(content_type) = catalog.get("type").and_then(Value::as_str) else { continue };
let Some(id) = catalog.get("id").and_then(Value::as_str) else { continue };
let Some(content_type) = catalog.get("type").and_then(Value::as_str) else {
continue;
};
let Some(id) = catalog.get("id").and_then(Value::as_str) else {
continue;
};
requests.push(json!({
"url": build_resource_url(transport_url, "catalog", content_type, id, Some(&json!({"search": query}).to_string())),
"kind": "search",
@ -143,9 +149,12 @@ pub(crate) fn resource_fetch_plan_json(request_json: &str) -> Option<String> {
}
"discover" => {
let genre = request.genre.as_deref();
for catalog in discover_catalog_options(&request.addons, request.content_type.as_deref().unwrap_or("")) {
for catalog in discover_catalog_options(
&request.addons,
request.content_type.as_deref().unwrap_or(""),
) {
let extra = genre.map(|value| json!({"genre": value}).to_string());
requests.push(json!({
requests.push(json!({
"url": build_resource_url(
&catalog.transport_url,
"catalog",
@ -165,7 +174,9 @@ pub(crate) fn resource_fetch_plan_json(request_json: &str) -> Option<String> {
if !addon_supports(addon, "meta", content_type, Some(id)) {
continue;
}
let Some(transport_url) = addon_transport_url(addon) else { continue };
let Some(transport_url) = addon_transport_url(addon) else {
continue;
};
requests.push(json!({
"url": build_resource_url(transport_url, "meta", content_type, id, None),
"kind": "metaDetail",
@ -180,7 +191,9 @@ pub(crate) fn resource_fetch_plan_json(request_json: &str) -> Option<String> {
if !addon_supports(addon, "stream", content_type, None) {
continue;
}
let Some(transport_url) = addon_transport_url(addon) else { continue };
let Some(transport_url) = addon_transport_url(addon) else {
continue;
};
for id in &request.request_ids {
requests.push(json!({
"url": build_resource_url(transport_url, "stream", content_type, id, None),
@ -196,7 +209,9 @@ pub(crate) fn resource_fetch_plan_json(request_json: &str) -> Option<String> {
if !addon_supports(addon, "meta", "series", Some(series_id)) {
continue;
}
let Some(transport_url) = addon_transport_url(addon) else { continue };
let Some(transport_url) = addon_transport_url(addon) else {
continue;
};
requests.push(json!({
"url": build_resource_url(transport_url, "meta", "series", series_id, None),
"kind": "seasonEpisodes",
@ -212,7 +227,9 @@ pub(crate) fn resource_fetch_plan_json(request_json: &str) -> Option<String> {
if !addon_supports(addon, "subtitles", content_type, Some(id)) {
continue;
}
let Some(transport_url) = addon_transport_url(addon) else { continue };
let Some(transport_url) = addon_transport_url(addon) else {
continue;
};
requests.push(json!({
"url": build_resource_url(transport_url, "subtitles", content_type, id, None),
"kind": "subtitles",
@ -308,14 +325,16 @@ pub(crate) fn playback_prepare_plan_json(request_json: &str) -> Option<String> {
.and_then(Value::as_bool)
.unwrap_or(false)
|| playable_url.starts_with("stremio://torrent/")
|| request.stream.get("infoHash").and_then(Value::as_str).is_some();
|| request
.stream
.get("infoHash")
.and_then(Value::as_str)
.is_some();
let compatible = info
.get("isLikelyPlayerCompatible")
.and_then(Value::as_bool)
.unwrap_or(true);
let mode = if playable_url.is_empty() {
"reject"
} else if !compatible {
let mode = if playable_url.is_empty() || !compatible {
"reject"
} else if is_torrent {
"torrent"
@ -439,26 +458,29 @@ pub(crate) fn detail_episode_plan_json(request_json: &str) -> Option<String> {
seasons.dedup();
// Search for the target episode across ALL episodes before season filtering,
// so that a lastVideoId from a later season (e.g. S9 when default would be S1) is found.
let target_episode = request
.selected_episode_id
.as_deref()
.and_then(|id| {
request
.episodes
.iter()
.find(|ep| ep.get("id").and_then(Value::as_str) == Some(id))
.cloned()
});
let target_episode = request.selected_episode_id.as_deref().and_then(|id| {
request
.episodes
.iter()
.find(|ep| ep.get("id").and_then(Value::as_str) == Some(id))
.cloned()
});
let selected_season = target_episode
.as_ref()
.and_then(|ep| ep.get("season").and_then(Value::as_i64))
.or_else(|| request.selected_season.filter(|season| seasons.contains(season)))
.or_else(|| {
request
.selected_season
.filter(|season| seasons.contains(season))
})
.or_else(|| seasons.first().copied())
.unwrap_or(1);
let episodes = request
.episodes
.into_iter()
.filter(|episode| episode.get("season").and_then(Value::as_i64).unwrap_or(1) == selected_season)
.filter(|episode| {
episode.get("season").and_then(Value::as_i64).unwrap_or(1) == selected_season
})
.collect::<Vec<_>>();
let selected_episode = target_episode
.filter(|ep| ep.get("season").and_then(Value::as_i64).unwrap_or(1) == selected_season)
@ -495,7 +517,10 @@ fn addon_transport_url(addon: &Value) -> Option<&str> {
}
fn addon_manifest(addon: &Value) -> Value {
addon.get("manifest").cloned().unwrap_or_else(|| addon.clone())
addon
.get("manifest")
.cloned()
.unwrap_or_else(|| addon.clone())
}
fn addon_catalogs(addon: &Value) -> Vec<Value> {
@ -512,8 +537,13 @@ fn addon_supports(addon: &Value, resource: &str, content_type: &str, id: Option<
}
fn addon_display_name(addon: &Value) -> String {
addon.get("name")
.or_else(|| addon.get("manifest").and_then(|manifest| manifest.get("name")))
addon
.get("name")
.or_else(|| {
addon
.get("manifest")
.and_then(|manifest| manifest.get("name"))
})
.and_then(Value::as_str)
.unwrap_or("Unknown Addon")
.to_string()
@ -558,10 +588,16 @@ struct DiscoverCatalog {
fn discover_catalog_options(addons: &[Value], selected_type: &str) -> Vec<DiscoverCatalog> {
let mut options = Vec::new();
for addon in addons {
let Some(transport_url) = addon_transport_url(addon) else { continue };
let Some(transport_url) = addon_transport_url(addon) else {
continue;
};
for catalog in addon_catalogs(addon) {
let Some(content_type) = catalog.get("type").and_then(Value::as_str) else { continue };
let Some(id) = catalog.get("id").and_then(Value::as_str) else { continue };
let Some(content_type) = catalog.get("type").and_then(Value::as_str) else {
continue;
};
let Some(id) = catalog.get("id").and_then(Value::as_str) else {
continue;
};
if !selected_type.is_empty() && content_type != selected_type {
continue;
}
@ -583,7 +619,9 @@ fn playback_title(meta: Option<&Value>, episode: Option<&Value>, stream: &Value)
.or_else(|| stream.get("name"))
.and_then(Value::as_str)
.unwrap_or("Fluxa");
let season = episode.and_then(|value| value.get("season")).and_then(Value::as_i64);
let season = episode
.and_then(|value| value.get("season"))
.and_then(Value::as_i64);
let episode_number = episode
.and_then(|value| value.get("episode").or_else(|| value.get("number")))
.and_then(Value::as_i64);
@ -593,10 +631,12 @@ fn playback_title(meta: Option<&Value>, episode: Option<&Value>, stream: &Value)
let episode_line = match (season, episode_number) {
(Some(season), Some(number)) => {
let prefix = format!("S{season}:E{number}");
Some(match episode_name.filter(|value| !value.trim().is_empty()) {
Some(name) => format!("{prefix} {}", name.trim()),
None => prefix,
})
Some(
match episode_name.filter(|value| !value.trim().is_empty()) {
Some(name) => format!("{prefix} {}", name.trim()),
None => prefix,
},
)
}
_ => None,
};
@ -605,16 +645,33 @@ fn playback_title(meta: Option<&Value>, episode: Option<&Value>, stream: &Value)
fn playback_artwork(meta: Option<&Value>, episode: Option<&Value>) -> Value {
let background = meta
.and_then(|value| first_text(value, &["background", "backgroundUrl", "backdrop", "backdropUrl"]))
.or_else(|| episode.and_then(|value| value.get("thumbnail")).and_then(Value::as_str))
.or_else(|| meta.and_then(|value| value.get("poster")).and_then(Value::as_str));
let logo = meta.and_then(|value| first_text(value, &["logo", "logoUrl", "titleLogo", "titleLogoUrl"]));
.and_then(|value| {
first_text(
value,
&["background", "backgroundUrl", "backdrop", "backdropUrl"],
)
})
.or_else(|| {
episode
.and_then(|value| value.get("thumbnail"))
.and_then(Value::as_str)
})
.or_else(|| {
meta.and_then(|value| value.get("poster"))
.and_then(Value::as_str)
});
let logo =
meta.and_then(|value| first_text(value, &["logo", "logoUrl", "titleLogo", "titleLogoUrl"]));
json!({ "background": background, "logo": logo })
}
fn first_text<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> {
keys.iter()
.find_map(|key| value.get(*key).and_then(Value::as_str).filter(|text| !text.trim().is_empty()))
keys.iter().find_map(|key| {
value
.get(*key)
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty())
})
}
fn normalize_preference_value(key: &str, value: Value) -> Value {
@ -629,7 +686,9 @@ fn normalize_preference_value(key: &str, value: Value) -> Value {
"preferred",
),
"torrentSpeedPreset" => enum_string(value, &["default", "fast", "ultra_fast"], "default"),
"torrentCachePreset" => enum_string(value, &["auto", "2gb", "5gb", "10gb", "unlimited"], "auto"),
"torrentCachePreset" => {
enum_string(value, &["auto", "2gb", "5gb", "10gb", "unlimited"], "auto")
}
"subtitleSize" => enum_string(value, &["50", "75", "100", "125", "150", "200"], "100"),
_ => value,
}
@ -645,9 +704,14 @@ fn enum_string(value: Value, allowed: &[&str], fallback: &str) -> Value {
}
fn addon_key(addon: &Value) -> String {
addon.get("transportUrl")
addon
.get("transportUrl")
.or_else(|| addon.get("id"))
.or_else(|| addon.get("manifest").and_then(|manifest| manifest.get("id")))
.or_else(|| {
addon
.get("manifest")
.and_then(|manifest| manifest.get("id"))
})
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
@ -655,7 +719,11 @@ fn addon_key(addon: &Value) -> 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 {
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()));
@ -749,7 +817,10 @@ mod tests {
assert_eq!(requests.len(), 1);
assert_eq!(requests[0]["kind"], "catalogPage");
assert!(requests[0]["url"].as_str().unwrap().contains("genre=action"));
assert!(requests[0]["url"]
.as_str()
.unwrap()
.contains("genre=action"));
}
#[test]
@ -773,9 +844,16 @@ mod tests {
.expect("plan");
let requests = plan["requests"].as_array().unwrap();
assert_eq!(requests.len(), 1, "catalog without search support must be excluded");
assert_eq!(
requests.len(),
1,
"catalog without search support must be excluded"
);
assert_eq!(requests[0]["catalogId"], "top");
assert_eq!(requests[0]["categoryName"], "Addon One - Top Movies");
assert!(requests[0]["url"].as_str().unwrap().contains("search=batman"));
assert!(requests[0]["url"]
.as_str()
.unwrap()
.contains("search=batman"));
}
}

View file

@ -85,7 +85,10 @@ pub(crate) fn player_flow_dispatch_json(state_json: &str, action_json: &str) ->
serde_json::to_string(&PlayerFlowResult { state, effects }).ok()
}
pub(crate) fn dispatch(state: &mut PlayerFlowState, action: PlayerFlowAction) -> Vec<PlayerFlowEffect> {
pub(crate) fn dispatch(
state: &mut PlayerFlowState,
action: PlayerFlowAction,
) -> Vec<PlayerFlowEffect> {
match action {
PlayerFlowAction::LoadStreamsRequested {
content_type,

View file

@ -102,7 +102,10 @@ pub(crate) fn player_backend_selection_json(request_json: &str) -> Option<String
.unwrap_or(false);
let is_dv_stream = stream.get("dv").and_then(Value::as_bool).unwrap_or(false)
|| stream.get("dolbyVision").and_then(Value::as_bool).unwrap_or(false);
|| stream
.get("dolbyVision")
.and_then(Value::as_bool)
.unwrap_or(false);
let is_hdr_stream = stream.get("hdr").and_then(Value::as_bool).unwrap_or(false);
let needs_mpv_for_hdr = (is_dv_stream && !request.device_has_dolby_vision_decoder)
|| (is_hdr_stream && !request.device_has_hdr_display);
@ -149,7 +152,11 @@ pub(crate) fn torrent_fallback_file_policy_json(request_json: &str) -> Option<St
if rejected == Some(id) {
return None;
}
let path = stat.get("path").and_then(Value::as_str).unwrap_or("").to_lowercase();
let path = stat
.get("path")
.and_then(Value::as_str)
.unwrap_or("")
.to_lowercase();
let is_video = video_exts.iter().any(|ext| path.ends_with(ext));
if !is_video {
return None;
@ -212,17 +219,9 @@ pub(crate) fn player_buffer_targets_json(request_json: &str) -> Option<String> {
_ => 1.0,
};
let base_forward_ms = request
.forward_buffer_seconds
.unwrap_or(120)
.clamp(10, 600) as f64
* 1000.0
* data_factor;
let base_back_ms = request
.back_buffer_seconds
.unwrap_or(30)
.clamp(5, 120) as f64
* 1000.0;
let base_forward_ms =
request.forward_buffer_seconds.unwrap_or(120).clamp(10, 600) as f64 * 1000.0 * data_factor;
let base_back_ms = request.back_buffer_seconds.unwrap_or(30).clamp(5, 120) as f64 * 1000.0;
// Torrent streams need smaller buffers to avoid filling the local proxy
let (forward_ms, back_ms) = if request.is_torrent {
@ -425,23 +424,14 @@ pub(crate) fn dv_proxy_plan_json(request_json: &str) -> Option<String> {
return plan_rich("none", "user_disabled", "unknown", "none", "high", &[]);
}
// HLS / DASH manifests are rewritten by the OkHttp interceptor — no proxy needed.
let url_lower = req.url.to_lowercase();
if url_lower.ends_with(".m3u8")
|| url_lower.contains(".m3u8?")
|| url_lower.ends_with(".mpd")
|| url_lower.contains(".mpd?")
{
return plan_rich("none", "manifest_handled", "unknown", "none", "high", &[]);
}
let is_hls = url_lower.ends_with(".m3u8") || url_lower.contains(".m3u8?");
let is_dash = url_lower.ends_with(".mpd") || url_lower.contains(".mpd?");
if !is_dolby_vision_stream(&req.stream, &req.url) {
return plan_rich("none", "not_dv", "unknown", "none", "high", &[]);
}
// Native passthrough: decoder + display always wins regardless of mode.
// Exception for convert_dv81: decoder without display should still convert P7 → P8.1
// so the decoder applies dynamic RPU tone mapping rather than static HDR10.
let native_passthrough = req.device_has_dv_decoder
&& (req.device_has_dv_display || req.fallback_mode != "convert_dv81");
if native_passthrough {
@ -451,6 +441,20 @@ pub(crate) fn dv_proxy_plan_json(request_json: &str) -> Option<String> {
let profile = detect_dv_profile(&req.stream);
let container = detect_container(&req.url);
if is_hls || is_dash {
if is_hls && matches!(profile, DvProfile::P7) && req.fallback_mode == "convert_dv81" && req.device_has_dv_decoder {
return plan_rich(
"hls_rpu_convert",
"p7_hls_segment_rpu_convert",
profile.label(),
"DV8",
"medium",
&[],
);
}
return plan_rich("none", "manifest_handled", profile.label(), "none", "high", &[]);
}
// Hard safety gates: profiles with no HDR base layer cannot be safely
// rewritten — stripping DVCC would expose a DV-only bitstream to an
// HDR10 decoder, producing corrupted colour.
@ -525,14 +529,26 @@ pub(crate) fn dv_proxy_plan_json(request_json: &str) -> Option<String> {
"HDR10",
"medium",
"rpu_convert_rejected_not_annexb",
vec!["rpu_convert_requires_annexb_hevc", "container_is_not_raw_hevc_fallback_to_dvcc_strip", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"],
vec![
"rpu_convert_requires_annexb_hevc",
"container_is_not_raw_hevc_fallback_to_dvcc_strip",
"header_only_patch",
"does_not_transcode",
"does_not_remove_rpu_nals",
],
),
_ => (
"dvcc_strip",
"HDR10",
"medium",
"p7_dvcc_strip_hdr10_base",
vec!["does_not_convert_bitstream", "rpu_nals_remain_in_stream_ignored", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"],
vec![
"does_not_convert_bitstream",
"rpu_nals_remain_in_stream_ignored",
"header_only_patch",
"does_not_transcode",
"does_not_remove_rpu_nals",
],
),
},
DvProfile::P8Hdr10 => (
@ -540,39 +556,70 @@ pub(crate) fn dv_proxy_plan_json(request_json: &str) -> Option<String> {
"HDR10",
"low",
"p8_1_hdr10_compat_base",
vec!["single_layer_hdr10_base_fully_compatible", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"],
vec![
"single_layer_hdr10_base_fully_compatible",
"header_only_patch",
"does_not_transcode",
"does_not_remove_rpu_nals",
],
),
DvProfile::P8Hlg => (
"dvcc_strip",
"HLG",
"medium",
"p8_4_hlg_compat_base",
vec!["hlg_base_not_hdr10_color_rendering_may_differ", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"],
vec![
"hlg_base_not_hdr10_color_rendering_may_differ",
"header_only_patch",
"does_not_transcode",
"does_not_remove_rpu_nals",
],
),
DvProfile::P8Unknown => (
"dvcc_strip",
"HDR10_assumed",
"medium",
"p8_compat_id_unknown_hdr10_assumed",
vec!["compat_id_unknown_hdr10_base_assumed", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"],
vec![
"compat_id_unknown_hdr10_base_assumed",
"header_only_patch",
"does_not_transcode",
"does_not_remove_rpu_nals",
],
),
DvProfile::P10Hdr10 => (
"dvcc_strip",
"HDR10",
"medium",
"p10_compat_id_1_hdr10_base",
vec!["does_not_convert_bitstream", "header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"],
vec![
"does_not_convert_bitstream",
"header_only_patch",
"does_not_transcode",
"does_not_remove_rpu_nals",
],
),
_ => (
"dvcc_strip",
"HDR10_assumed",
"medium",
"unknown_profile_dvcc_strip_fallback",
vec!["header_only_patch", "does_not_transcode", "does_not_remove_rpu_nals"],
vec![
"header_only_patch",
"does_not_transcode",
"does_not_remove_rpu_nals",
],
),
};
plan_rich(action, reason, profile.label(), compat, safety, &limitations)
plan_rich(
action,
reason,
profile.label(),
compat,
safety,
&limitations,
)
}
fn plan_rich(
@ -618,7 +665,10 @@ fn detect_dv_profile(stream: &Value) -> DvProfile {
// 3. Codec token embedded in freetext fields (e.g., "dvhe.07.06 BDRemux").
let name = stream.get("name").and_then(Value::as_str).unwrap_or("");
let desc = stream.get("description").and_then(Value::as_str).unwrap_or("");
let desc = stream
.get("description")
.and_then(Value::as_str)
.unwrap_or("");
let filename = stream
.get("effectiveFilename")
.or_else(|| stream.get("filename"))
@ -660,7 +710,8 @@ fn parse_dv_codec_string(text: &str) -> Option<DvProfile> {
let mut parts = after.splitn(3, '.');
// Take only the leading digits from each field (e.g. "08" from "08.01 Remux").
let profile: i64 = leading_digits(parts.next()?)?.parse().ok()?;
let compat: Option<i64> = parts.next()
let compat: Option<i64> = parts
.next()
.and_then(leading_digits)
.and_then(|s| s.parse().ok());
return Some(profile_from_nums(profile, compat));
@ -671,7 +722,11 @@ fn parse_dv_codec_string(text: &str) -> Option<DvProfile> {
fn leading_digits(s: &str) -> Option<&str> {
let end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
if end == 0 { None } else { Some(&s[..end]) }
if end == 0 {
None
} else {
Some(&s[..end])
}
}
/// Recognise short profile tokens ("P8.1", "P7", "P8") in freetext.
@ -680,11 +735,11 @@ fn parse_dv_profile_text(text: &str) -> Option<DvProfile> {
let patterns: &[(&str, DvProfile)] = &[
("P8.1", DvProfile::P8Hdr10),
("P8.4", DvProfile::P8Hlg),
("P7", DvProfile::P7),
("P8", DvProfile::P8Unknown),
("P10", DvProfile::P10Other),
("P5", DvProfile::P5),
("P4", DvProfile::P4),
("P7", DvProfile::P7),
("P8", DvProfile::P8Unknown),
("P10", DvProfile::P10Other),
("P5", DvProfile::P5),
("P4", DvProfile::P4),
];
for (pat, profile) in patterns {
if contains_word(text, pat) {
@ -717,14 +772,20 @@ fn contains_word(text: &str, word: &str) -> bool {
/// Returns true when the stream or URL is identifiable as Dolby Vision content.
fn is_dolby_vision_stream(stream: &Value, url: &str) -> bool {
if stream.get("dv").and_then(Value::as_bool).unwrap_or(false)
|| stream.get("dolbyVision").and_then(Value::as_bool).unwrap_or(false)
|| stream
.get("dolbyVision")
.and_then(Value::as_bool)
.unwrap_or(false)
|| stream.get("dvProfile").and_then(Value::as_i64).is_some()
{
return true;
}
let name = stream.get("name").and_then(Value::as_str).unwrap_or("");
let desc = stream.get("description").and_then(Value::as_str).unwrap_or("");
let desc = stream
.get("description")
.and_then(Value::as_str)
.unwrap_or("");
let filename = stream
.get("effectiveFilename")
.or_else(|| stream.get("filename"))
@ -807,7 +868,10 @@ fn episode_path_matches_id(path: &str, video_id: &str) -> bool {
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 try_binge = prefs
.get("tryBingeGroup")
.and_then(Value::as_bool)
.unwrap_or(false);
let mode = prefs
.get("streamSourceSelectionMode")
.and_then(Value::as_str)
@ -816,7 +880,7 @@ pub(crate) fn can_prefetch_next_episode_json(prefs_json: &str, stream_json: &str
.get("behaviorHints")
.and_then(|h| h.get("bingeGroup"))
.and_then(Value::as_str)
.map_or(false, |s| !s.is_empty());
.is_some_and(|s| !s.is_empty());
(try_binge && has_binge_group) || mode != "manual"
}
@ -829,13 +893,24 @@ pub(crate) fn select_next_episode_stream_json(
prefs_json: &str,
) -> Option<String> {
let streams: Vec<Value> = serde_json::from_str(streams_json).ok()?;
if streams.is_empty() { return None; }
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 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"))
@ -857,14 +932,24 @@ pub(crate) fn select_next_episode_stream_json(
}
if mode == "regex" && !regex_pat.is_empty() {
if let Ok(re) = regex::RegexBuilder::new(regex_pat).case_insensitive(true).build() {
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(" ")
[
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();
@ -883,10 +968,8 @@ mod tests {
#[test]
fn backend_selection_defaults_to_exoplayer() {
let result: Value = serde_json::from_str(
&player_backend_selection_json(
r#"{"stream":{"url":"http://example.com/video.mp4"}}"#,
)
.unwrap(),
&player_backend_selection_json(r#"{"stream":{"url":"http://example.com/video.mp4"}}"#)
.unwrap(),
)
.unwrap();
assert_eq!(result["backend"], "exoplayer");
@ -940,7 +1023,10 @@ mod tests {
.unwrap(),
)
.unwrap();
assert!(torrent_result["forwardBufferMs"].as_i64().unwrap() < direct_result["forwardBufferMs"].as_i64().unwrap());
assert!(
torrent_result["forwardBufferMs"].as_i64().unwrap()
< direct_result["forwardBufferMs"].as_i64().unwrap()
);
}
#[test]
@ -971,42 +1057,54 @@ mod tests {
#[test]
fn dv_proxy_off_mode_returns_none() {
let p = plan(r#"{"stream":{"name":"4K DV HDR","dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"off"}"#);
let p = plan(
r#"{"stream":{"name":"4K DV HDR","dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"off"}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "user_disabled");
}
#[test]
fn dv_proxy_hls_url_defers_to_manifest_rewrite() {
let p = plan(r#"{"stream":{"name":"4K DV","dvProfile":7},"url":"https://cdn.example/index.m3u8","fallbackMode":"auto"}"#);
let p = plan(
r#"{"stream":{"name":"4K DV","dvProfile":7},"url":"https://cdn.example/index.m3u8","fallbackMode":"auto"}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "manifest_handled");
}
#[test]
fn dv_proxy_dash_url_defers_to_manifest_rewrite() {
let p = plan(r#"{"stream":{"name":"4K DV","dvProfile":7},"url":"https://cdn.example/stream.mpd","fallbackMode":"auto"}"#);
let p = plan(
r#"{"stream":{"name":"4K DV","dvProfile":7},"url":"https://cdn.example/stream.mpd","fallbackMode":"auto"}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "manifest_handled");
}
#[test]
fn dv_proxy_non_dv_stream_returns_none() {
let p = plan(r#"{"stream":{"name":"1080p HDR AVC"},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto"}"#);
let p = plan(
r#"{"stream":{"name":"1080p HDR AVC"},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto"}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "not_dv");
}
#[test]
fn dv_proxy_hw_dv_decoder_skips_proxy() {
let p = plan(r#"{"stream":{"name":"4K DV","dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":true}"#);
let p = plan(
r#"{"stream":{"name":"4K DV","dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":true}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "hw_dv_decoder");
}
#[test]
fn dv_proxy_p5_no_dv_decoder_returns_none() {
let p = plan(r#"{"stream":{"dvProfile":5},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#);
let p = plan(
r#"{"stream":{"dvProfile":5},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "no_hdr_base_layer");
assert_eq!(p["profile"], "P5");
@ -1014,7 +1112,9 @@ mod tests {
#[test]
fn dv_proxy_p4_no_dv_decoder_returns_none() {
let p = plan(r#"{"stream":{"dvProfile":4},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#);
let p = plan(
r#"{"stream":{"dvProfile":4},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "no_hdr_base_layer");
assert_eq!(p["profile"], "P4");
@ -1022,28 +1122,36 @@ mod tests {
#[test]
fn dv_proxy_p10_compat_0_returns_none() {
let p = plan(r#"{"stream":{"dvProfile":10,"dvCompatId":0},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#);
let p = plan(
r#"{"stream":{"dvProfile":10,"dvCompatId":0},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "p10_compat_id_no_hdr_base");
}
#[test]
fn dv_proxy_p10_compat_2_returns_none() {
let p = plan(r#"{"stream":{"dvProfile":10,"dvCompatId":2},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#);
let p = plan(
r#"{"stream":{"dvProfile":10,"dvCompatId":2},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#,
);
assert_eq!(p["action"], "none");
}
#[test]
fn dv_proxy_unknown_profile_returns_none() {
// DV detected but no profile info → safe default is none.
let p = plan(r#"{"stream":{"name":"Dolby Vision"},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#);
let p = plan(
r#"{"stream":{"name":"Dolby Vision"},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
}
#[test]
fn dv_proxy_p7_mkv_auto_gives_dvcc_strip_medium_safety() {
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false,"deviceHasDvDisplay":false}"#);
let p = plan(
r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false,"deviceHasDvDisplay":false}"#,
);
assert_eq!(p["action"], "dvcc_strip");
assert_eq!(p["profile"], "P7");
assert_eq!(p["compatibility"], "HDR10");
@ -1052,7 +1160,9 @@ mod tests {
#[test]
fn dv_proxy_p8_1_gives_dvcc_strip_low_safety() {
let p = plan(r#"{"stream":{"dvProfile":8,"dvCompatId":1},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#);
let p = plan(
r#"{"stream":{"dvProfile":8,"dvCompatId":1},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#,
);
assert_eq!(p["action"], "dvcc_strip");
assert_eq!(p["profile"], "P8.1");
assert_eq!(p["compatibility"], "HDR10");
@ -1061,7 +1171,9 @@ mod tests {
#[test]
fn dv_proxy_p8_4_fallback_is_hlg_not_hdr10() {
let p = plan(r#"{"stream":{"dvProfile":8,"dvCompatId":4},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#);
let p = plan(
r#"{"stream":{"dvProfile":8,"dvCompatId":4},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#,
);
assert_eq!(p["action"], "dvcc_strip");
assert_eq!(p["profile"], "P8.4");
assert_eq!(p["compatibility"], "HLG");
@ -1071,7 +1183,9 @@ mod tests {
#[test]
fn dv_proxy_p8_unknown_compat_strips_with_assumed_hdr10() {
// "DV P8" in name → P8Unknown → strip, medium safety, HDR10_assumed
let p = plan(r#"{"stream":{"name":"DV P8"},"url":"https://debrid.example/file.mkv","fallbackMode":"hdr10","deviceHasDvDecoder":false}"#);
let p = plan(
r#"{"stream":{"name":"DV P8"},"url":"https://debrid.example/file.mkv","fallbackMode":"hdr10","deviceHasDvDecoder":false}"#,
);
assert_eq!(p["action"], "dvcc_strip");
assert_eq!(p["profile"], "P8");
assert_eq!(p["compatibility"], "HDR10_assumed");
@ -1080,7 +1194,9 @@ mod tests {
#[test]
fn dv_proxy_p10_compat_1_gives_dvcc_strip() {
let p = plan(r#"{"stream":{"dvProfile":10,"dvCompatId":1},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#);
let p = plan(
r#"{"stream":{"dvProfile":10,"dvCompatId":1},"url":"https://cdn.example/movie.mkv","fallbackMode":"auto","deviceHasDvDecoder":false}"#,
);
assert_eq!(p["action"], "dvcc_strip");
assert_eq!(p["profile"], "P10_compat1");
assert_eq!(p["compatibility"], "HDR10");
@ -1088,7 +1204,9 @@ mod tests {
#[test]
fn dv_proxy_p7_raw_hevc_dv8_mode_gives_rpu_convert() {
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/stream.hevc","fallbackMode":"dv8","deviceHasDvDecoder":false}"#);
let p = plan(
r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/stream.hevc","fallbackMode":"dv8","deviceHasDvDecoder":false}"#,
);
assert_eq!(p["action"], "rpu_convert");
assert_eq!(p["rpuMode"], 2);
assert_eq!(p["profile"], "P7");
@ -1096,7 +1214,9 @@ mod tests {
#[test]
fn dv_proxy_p7_raw_hevc_auto_dv_display_gives_rpu_convert() {
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/stream.hevc","fallbackMode":"auto","deviceHasDvDecoder":false,"deviceHasDvDisplay":true}"#);
let p = plan(
r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/stream.hevc","fallbackMode":"auto","deviceHasDvDecoder":false,"deviceHasDvDisplay":true}"#,
);
assert_eq!(p["action"], "rpu_convert");
}
@ -1105,14 +1225,18 @@ mod tests {
// dv8 mode + MKV without a DV decoder → falls back to dvcc_strip because
// rpu_convert needs a DV decoder in the convert_dv81 path, and dv8 mode
// is annexb-only (rejects non-raw-HEVC containers).
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"dv8","deviceHasDvDecoder":false}"#);
let p = plan(
r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"dv8","deviceHasDvDecoder":false}"#,
);
assert_eq!(p["action"], "dvcc_strip");
assert_eq!(p["reason"], "rpu_convert_rejected_not_annexb");
}
#[test]
fn dv_proxy_rpu_convert_rejected_for_mp4_falls_back_to_dvcc_strip() {
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mp4","fallbackMode":"dv8","deviceHasDvDecoder":false}"#);
let p = plan(
r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mp4","fallbackMode":"dv8","deviceHasDvDecoder":false}"#,
);
assert_eq!(p["action"], "dvcc_strip");
assert_eq!(p["reason"], "rpu_convert_rejected_not_annexb");
}
@ -1120,7 +1244,9 @@ mod tests {
#[test]
fn dv_detection_dolby_vision_p8_text_gives_action() {
// "P8" token → P8Unknown → dvcc_strip
let p = plan(r#"{"stream":{"name":"Dolby Vision P8"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
let p = plan(
r#"{"stream":{"name":"Dolby Vision P8"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#,
);
assert_ne!(p["action"], "none");
assert_eq!(p["profile"], "P8");
}
@ -1128,7 +1254,9 @@ mod tests {
#[test]
fn dv_detection_dovi_without_profile_gives_none() {
// DV detected ("dovi") but no profile info → unknown → none.
let p = plan(r#"{"stream":{"name":"4K DoVi 5.1"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
let p = plan(
r#"{"stream":{"name":"4K DoVi 5.1"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
}
@ -1136,21 +1264,27 @@ mod tests {
#[test]
fn dv_detection_standalone_dv_without_profile_gives_none() {
// "[DV]" detected but no profile info → none.
let p = plan(r#"{"stream":{"name":"[4K] [DV] [HDR10+]"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
let p = plan(
r#"{"stream":{"name":"[4K] [DV] [HDR10+]"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
}
#[test]
fn dv_detection_dvhe_fourcc_in_name_gives_profile_p7() {
let p = plan(r#"{"stream":{"name":"dvhe.07.06 BDRemux"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
let p = plan(
r#"{"stream":{"name":"dvhe.07.06 BDRemux"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#,
);
assert_ne!(p["action"], "none");
assert_eq!(p["profile"], "P7");
}
#[test]
fn dv_detection_dvhe_08_01_in_name_gives_p8_1() {
let p = plan(r#"{"stream":{"name":"dvhe.08.01 Remux"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
let p = plan(
r#"{"stream":{"name":"dvhe.08.01 Remux"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#,
);
assert_eq!(p["action"], "dvcc_strip");
assert_eq!(p["profile"], "P8.1");
assert_eq!(p["safety"], "low");
@ -1158,20 +1292,26 @@ mod tests {
#[test]
fn dv_detection_no_false_positive_from_dvd() {
let p = plan(r#"{"stream":{"name":"DVD Rip 1080p"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
let p = plan(
r#"{"stream":{"name":"DVD Rip 1080p"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "not_dv");
}
#[test]
fn dv_detection_no_false_positive_from_hdvd() {
let p = plan(r#"{"stream":{"name":"HDVD Edition"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
let p = plan(
r#"{"stream":{"name":"HDVD Edition"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#,
);
assert_eq!(p["action"], "none");
}
#[test]
fn dv_detection_explicit_boolean_flag_with_profile() {
let p = plan(r#"{"stream":{"dv":true,"dvProfile":8,"dvCompatId":1,"name":"4K HDR"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
let p = plan(
r#"{"stream":{"dv":true,"dvProfile":8,"dvCompatId":1,"name":"4K HDR"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#,
);
assert_ne!(p["action"], "none");
assert_eq!(p["profile"], "P8.1");
}
@ -1179,14 +1319,18 @@ mod tests {
#[test]
fn dv_detection_filename_without_profile_gives_none() {
// DV keyword in filename but no profile → safe default is none.
let p = plan(r#"{"stream":{"name":"4K HDR","effectiveFilename":"Movie.2023.UHD.DV.HEVC.mkv"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
let p = plan(
r#"{"stream":{"name":"4K HDR","effectiveFilename":"Movie.2023.UHD.DV.HEVC.mkv"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
}
#[test]
fn dv_detection_dvhe_codec_in_filename_gives_profile() {
let p = plan(r#"{"stream":{"effectiveFilename":"Movie.dvhe.07.06.mkv"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#);
let p = plan(
r#"{"stream":{"effectiveFilename":"Movie.dvhe.07.06.mkv"},"url":"https://cdn.example/f.mkv","fallbackMode":"auto"}"#,
);
assert_ne!(p["action"], "none");
assert_eq!(p["profile"], "P7");
}
@ -1197,7 +1341,8 @@ mod tests {
fn sample_p5_dvonly_no_fallback() {
// P5 is HEVC single-layer with no HDR base. Stripping DVCC would expose
// a DV-only bitstream to an HDR10 decoder → broken colour. Never rewrite.
let p = plan(r#"{
let p = plan(
r#"{
"stream": {
"name": "AETHER | 4K | Dolby Vision | DD+ Atmos",
"description": "📺 4K | 🎬 dvhe.05.06 | 🔊 DD+ Atmos",
@ -1206,19 +1351,23 @@ mod tests {
"url": "https://debrid.example/movie.mkv",
"fallbackMode": "auto",
"deviceHasDvDecoder": false
}"#);
}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "no_hdr_base_layer");
assert_eq!(p["profile"], "P5");
let limitations = p["limitations"].as_array().unwrap();
assert!(limitations.iter().any(|l| l.as_str().unwrap().contains("p4_p5")));
assert!(limitations
.iter()
.any(|l| l.as_str().unwrap().contains("p4_p5")));
}
#[test]
fn sample_p7_dual_layer_hdr10_fallback() {
// P7 BL+EL: stripping DVCC reveals the HDR10 base layer. Medium risk —
// RPU NALs remain in-stream but HEVC decoders ignore them.
let p = plan(r#"{
let p = plan(
r#"{
"stream": {
"name": "FLUX | 4K | dvhe.07.06 | Atmos",
"description": "HDR10 + Dolby Vision P7 BL+EL remux",
@ -1228,20 +1377,24 @@ mod tests {
"fallbackMode": "auto",
"deviceHasDvDecoder": false,
"deviceHasDvDisplay": false
}"#);
}"#,
);
assert_eq!(p["action"], "dvcc_strip");
assert_eq!(p["profile"], "P7");
assert_eq!(p["compatibility"], "HDR10");
assert_eq!(p["safety"], "medium");
let limitations = p["limitations"].as_array().unwrap();
assert!(limitations.iter().any(|l| l.as_str().unwrap().contains("does_not_convert_bitstream")));
assert!(limitations
.iter()
.any(|l| l.as_str().unwrap().contains("does_not_convert_bitstream")));
}
#[test]
fn sample_p8_1_single_layer_low_risk_fallback() {
// P8.1 has an HDR10-compatible base layer encoded into the single HEVC stream.
// Stripping DVCC gives clean HDR10 output. Lowest-risk rewrite.
let p = plan(r#"{
let p = plan(
r#"{
"stream": {
"name": "HDMUX | 4K | dvhe.08.01 | TrueHD Atmos",
"dvProfile": 8,
@ -1250,7 +1403,8 @@ mod tests {
"url": "https://debrid.example/Movie.2023.2160p.DV.HEVC.mkv",
"fallbackMode": "auto",
"deviceHasDvDecoder": false
}"#);
}"#,
);
assert_eq!(p["action"], "dvcc_strip");
assert_eq!(p["profile"], "P8.1");
assert_eq!(p["compatibility"], "HDR10");
@ -1261,7 +1415,8 @@ mod tests {
fn sample_p8_4_hlg_base_not_hdr10() {
// P8.4 has an HLG base layer, not HDR10. Rewriting it as HDR10 would
// produce incorrect colour. The compatibility field must reflect HLG.
let p = plan(r#"{
let p = plan(
r#"{
"stream": {
"name": "BBC iPlayer | 4K | Dolby Vision HLG | AAC",
"dvProfile": 8,
@ -1270,12 +1425,15 @@ mod tests {
"url": "https://cdn.example/show_ep01.mkv",
"fallbackMode": "auto",
"deviceHasDvDecoder": false
}"#);
}"#,
);
assert_eq!(p["action"], "dvcc_strip");
assert_eq!(p["profile"], "P8.4");
assert_eq!(p["compatibility"], "HLG");
assert_ne!(p["compatibility"], "HDR10",
"P8.4 has HLG base, must not be labelled HDR10");
assert_ne!(
p["compatibility"], "HDR10",
"P8.4 has HLG base, must not be labelled HDR10"
);
assert_eq!(p["safety"], "medium");
}
@ -1283,7 +1441,8 @@ mod tests {
fn sample_unknown_profile_from_addon_with_only_dv_keyword() {
// Many addons only set a "Dolby Vision" label without specifying the
// profile. Without profile info the only safe action is none.
let p = plan(r#"{
let p = plan(
r#"{
"stream": {
"name": "4K | Dolby Vision | DD+ Atmos",
"description": "UHD Remux"
@ -1291,18 +1450,22 @@ mod tests {
"url": "https://debrid.example/movie.mkv",
"fallbackMode": "auto",
"deviceHasDvDecoder": false
}"#);
}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
let limitations = p["limitations"].as_array().unwrap();
assert!(limitations.iter().any(|l| l.as_str().unwrap().contains("set_dvProfile_field")));
assert!(limitations
.iter()
.any(|l| l.as_str().unwrap().contains("set_dvProfile_field")));
}
#[test]
fn sample_p7_rpu_convert_on_raw_hevc_dv8_mode() {
// Raw Annex-B HEVC + P7 + dv8 mode → live RPU conversion. The only
// case where rpu_convert is emitted instead of dvcc_strip.
let p = plan(r#"{
let p = plan(
r#"{
"stream": {
"name": "RAW HEVC | 4K | dvhe.07.06",
"dvProfile": 7
@ -1310,7 +1473,8 @@ mod tests {
"url": "https://cdn.example/stream.hevc",
"fallbackMode": "dv8",
"deviceHasDvDecoder": false
}"#);
}"#,
);
assert_eq!(p["action"], "rpu_convert");
assert_eq!(p["profile"], "P7");
assert_eq!(p["compatibility"], "DV8");
@ -1320,21 +1484,27 @@ mod tests {
#[test]
fn convert_dv81_p7_mkv_decoder_no_display_returns_rpu_convert() {
// Decoder present, no DV display: MKV now supported via EBML RPU rewriter.
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":false}"#);
let p = plan(
r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mkv","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":false}"#,
);
assert_eq!(p["action"], "rpu_convert");
assert_eq!(p["reason"], "p7_rpu_convert_to_dv81");
}
#[test]
fn convert_dv81_p7_mp4_decoder_no_display_returns_rpu_convert() {
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mp4","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":false}"#);
let p = plan(
r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mp4","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":false}"#,
);
assert_eq!(p["action"], "rpu_convert");
assert_eq!(p["reason"], "p7_rpu_convert_to_dv81");
}
#[test]
fn convert_dv81_p7_raw_hevc_decoder_no_display_returns_rpu_convert() {
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.hevc","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":false}"#);
let p = plan(
r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.hevc","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":false}"#,
);
assert_eq!(p["action"], "rpu_convert");
assert_eq!(p["reason"], "p7_rpu_convert_to_dv81");
}
@ -1342,7 +1512,9 @@ mod tests {
#[test]
fn convert_dv81_decoder_and_display_returns_native_passthrough() {
// Full DV device → native, no proxy needed.
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mp4","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":true}"#);
let p = plan(
r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mp4","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":true}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "hw_dv_decoder");
}
@ -1350,13 +1522,17 @@ mod tests {
#[test]
fn convert_dv81_no_decoder_falls_back_to_dvcc_strip() {
// No DV decoder → same as Auto: strip to HDR10.
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mp4","fallbackMode":"convert_dv81","deviceHasDvDecoder":false,"deviceHasDvDisplay":false}"#);
let p = plan(
r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/movie.mp4","fallbackMode":"convert_dv81","deviceHasDvDecoder":false,"deviceHasDvDisplay":false}"#,
);
assert_eq!(p["action"], "dvcc_strip");
}
#[test]
fn convert_dv81_hls_still_deferred_to_manifest_rewrite() {
let p = plan(r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/index.m3u8","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":false}"#);
let p = plan(
r#"{"stream":{"dvProfile":7},"url":"https://cdn.example/index.m3u8","fallbackMode":"convert_dv81","deviceHasDvDecoder":true,"deviceHasDvDisplay":false}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "manifest_handled");
}
@ -1365,7 +1541,8 @@ mod tests {
fn sample_hls_stream_always_deferred_to_manifest_rewrite() {
// HLS streams are handled by the OkHttp interceptor regardless of profile.
// The proxy must never be activated for .m3u8 URLs.
let p = plan(r#"{
let p = plan(
r#"{
"stream": {
"name": "Apple TV+ | 4K | dvhe.08.01",
"dvProfile": 8,
@ -1374,7 +1551,8 @@ mod tests {
"url": "https://cdn.example/master.m3u8",
"fallbackMode": "auto",
"deviceHasDvDecoder": false
}"#);
}"#,
);
assert_eq!(p["action"], "none");
assert_eq!(p["reason"], "manifest_handled");
}

View file

@ -72,7 +72,11 @@ pub(crate) fn trakt_scrobble_plan_json(
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 action = if progress as f32 >= SCROBBLE_STOP_PROGRESS_PERCENT {
"stop"
} else {
"pause"
};
let body = if is_episode {
serde_json::json!({
"show": { "ids": ids },

View file

@ -64,11 +64,7 @@ pub(crate) fn active_profile_plan_json(request_json: &str) -> Option<String> {
}
let stored = request.stored_active_id.as_deref().unwrap_or("").trim();
let active = if stored.is_empty() || stored == GUEST_PROFILE_ID {
request
.profiles
.first()
.cloned()
.unwrap_or(Value::Null)
request.profiles.first().cloned().unwrap_or(Value::Null)
} else {
request
.profiles
@ -106,7 +102,9 @@ pub(crate) fn token_merge_plan_json(request_json: &str) -> Option<String> {
match provider {
"trakt" => {
let token = auth.get("accessToken").or_else(|| auth.get("access_token"));
let refresh = auth.get("refreshToken").or_else(|| auth.get("refresh_token"));
let refresh = auth
.get("refreshToken")
.or_else(|| auth.get("refresh_token"));
let expires_at = auth
.get("expiresAt")
.or_else(|| auth.get("expires_at"))
@ -124,7 +122,9 @@ pub(crate) fn token_merge_plan_json(request_json: &str) -> Option<String> {
}
"mal" => {
let token = auth.get("accessToken").or_else(|| auth.get("access_token"));
let refresh = auth.get("refreshToken").or_else(|| auth.get("refresh_token"));
let refresh = auth
.get("refreshToken")
.or_else(|| auth.get("refresh_token"));
if let Some(t) = token {
obj.insert("malAccessToken".to_string(), t.clone());
}
@ -218,7 +218,8 @@ pub(crate) fn profile_settings_migration_plan_json(request_json: &str) -> Option
if let Some(addon_settings) = obj.remove("addonSettings") {
if let Some(addon_obj) = addon_settings.as_object() {
if let Some(local) = addon_obj.get("localAddons") {
obj.entry("localAddons".to_string()).or_insert(local.clone());
obj.entry("localAddons".to_string())
.or_insert(local.clone());
}
if let Some(disabled) = addon_obj.get("disabledLocalAddons") {
obj.entry("disabledLocalAddons".to_string())
@ -329,10 +330,7 @@ pub(crate) fn profile_settings_migration_plan_json(request_json: &str) -> Option
.and_then(Value::as_array)
.is_some_and(|arr| !arr.is_empty());
if !has_local_addons {
obj.insert(
"localAddons".to_string(),
json!([DEFAULT_ADDON_URL]),
);
obj.insert("localAddons".to_string(), json!([DEFAULT_ADDON_URL]));
applied.push("ensure_default_addon".to_string());
}
}
@ -379,10 +377,7 @@ mod tests {
#[test]
fn active_profile_plan_returns_first_when_no_stored_id() {
let result: Value = serde_json::from_str(
&active_profile_plan_json(
r#"{"profiles":[{"id":"p1"},{"id":"p2"}]}"#,
)
.unwrap(),
&active_profile_plan_json(r#"{"profiles":[{"id":"p1"},{"id":"p2"}]}"#).unwrap(),
)
.unwrap();
assert_eq!(result["activeId"], "p1");
@ -391,10 +386,8 @@ mod tests {
#[test]
fn active_profile_plan_creates_default_when_profiles_empty() {
let result: Value = serde_json::from_str(
&active_profile_plan_json(r#"{"profiles":[]}"#).unwrap(),
)
.unwrap();
let result: Value =
serde_json::from_str(&active_profile_plan_json(r#"{"profiles":[]}"#).unwrap()).unwrap();
assert_eq!(result["activeId"], "guest");
assert_eq!(result["shouldCreateDefault"], true);
}
@ -434,10 +427,7 @@ mod tests {
.unwrap(),
)
.unwrap();
assert_eq!(
result["migratedProfile"]["traktAccessToken"],
"tok"
);
assert_eq!(result["migratedProfile"]["traktAccessToken"], "tok");
assert!(result["appliedMigrations"]
.as_array()
.unwrap()
@ -455,7 +445,10 @@ mod tests {
)
.unwrap();
assert_eq!(result["migratedProfile"]["libraryCollections"][0]["id"], "c1");
assert_eq!(
result["migratedProfile"]["libraryCollections"][0]["id"],
"c1"
);
}
#[test]

View file

@ -133,7 +133,9 @@ fn profile_safe_prefs(profile: &Value) -> ProfileSafePrefs {
.to_string();
ProfileSafePrefs {
language: text(profile, "language").unwrap_or(DEFAULT_LANGUAGE).to_string(),
language: text(profile, "language")
.unwrap_or(DEFAULT_LANGUAGE)
.to_string(),
subtitle_size_percent,
subtitle_size: 20.0 * (subtitle_size_percent / 100.0),
subtitle_color: int(profile, "subtitleColor").unwrap_or(0xFFFF_FFFFu32 as i32 as i64),

View file

@ -31,6 +31,7 @@ pub enum EffectKind {
ReadDetailLocalState,
ReadDiscoverCatalogFilters,
ReadHomeBootstrap,
RefreshContinueWatching,
ReadLibraryState,
ReadPlaybackProgress,
RefreshAuthToken,
@ -78,6 +79,7 @@ impl EffectKind {
EffectKind::ReadDetailLocalState => "readDetailLocalState",
EffectKind::ReadDiscoverCatalogFilters => "readDiscoverCatalogFilters",
EffectKind::ReadHomeBootstrap => "readHomeBootstrap",
EffectKind::RefreshContinueWatching => "refreshContinueWatching",
EffectKind::ReadLibraryState => "readLibraryState",
EffectKind::ReadPlaybackProgress => "readPlaybackProgress",
EffectKind::RefreshAuthToken => "refreshAuthToken",
@ -100,6 +102,7 @@ impl EffectKind {
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(value: &str) -> Option<Self> {
Some(match value {
"clearPlaybackProgress" => EffectKind::ClearPlaybackProgress,
@ -125,6 +128,7 @@ impl EffectKind {
"readDetailLocalState" => EffectKind::ReadDetailLocalState,
"readDiscoverCatalogFilters" => EffectKind::ReadDiscoverCatalogFilters,
"readHomeBootstrap" => EffectKind::ReadHomeBootstrap,
"refreshContinueWatching" => EffectKind::RefreshContinueWatching,
"readLibraryState" => EffectKind::ReadLibraryState,
"readPlaybackProgress" => EffectKind::ReadPlaybackProgress,
"refreshAuthToken" => EffectKind::RefreshAuthToken,

View file

@ -1,5 +1,5 @@
use serde::Deserialize;
use crate::{addon_protocol, content_identity};
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashSet;
@ -48,11 +48,14 @@ struct LibrarySortRequest {
}
fn manifest_value(addon: &Value) -> Option<&Value> {
addon.get("manifest").or_else(|| Some(addon))
addon.get("manifest").or(Some(addon))
}
fn addon_transport_url(addon: &Value) -> &str {
addon.get("transportUrl").and_then(Value::as_str).unwrap_or("")
addon
.get("transportUrl")
.and_then(Value::as_str)
.unwrap_or("")
}
fn addon_manifest_name(addon: &Value) -> String {
@ -112,7 +115,9 @@ fn discover_catalog_label(raw_name: Option<&str>, id: &str) -> String {
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" ");
for word in ["cinemeta", "movie", "movies", "film", "films", "series", "shows", "tv"] {
for word in [
"cinemeta", "movie", "movies", "film", "films", "series", "shows", "tv",
] {
label = label
.split_whitespace()
.filter(|part| !part.eq_ignore_ascii_case(word))
@ -174,8 +179,9 @@ fn manifest_supports_catalog(manifest: &Value) -> bool {
}
fn catalog_has_required_extra_except(catalog: &Value, allowed: &[&str]) -> bool {
let allowed_json = serde_json::to_string(&allowed.iter().map(|s| s.to_string()).collect::<Vec<_>>())
.unwrap_or_else(|_| "[]".to_string());
let allowed_json =
serde_json::to_string(&allowed.iter().map(|s| s.to_string()).collect::<Vec<_>>())
.unwrap_or_else(|_| "[]".to_string());
serde_json::to_string(catalog)
.ok()
.is_some_and(|json| addon_protocol::catalog_has_required_extra_except(&json, &allowed_json))
@ -255,7 +261,10 @@ pub(crate) fn build_metadata_feed_options_json(addons_json: &str) -> Option<Stri
serde_json::to_string(&feeds).ok()
}
pub(crate) fn discover_catalog_options_json(addons_json: &str, selected_type: &str) -> Option<String> {
pub(crate) fn discover_catalog_options_json(
addons_json: &str,
selected_type: &str,
) -> Option<String> {
let addons = serde_json::from_str::<Vec<Value>>(addons_json).ok()?;
let normalized_type = selected_type.to_lowercase();
let mut options = Vec::new();
@ -381,19 +390,29 @@ pub(crate) fn discover_sort_plan_json(request_json: &str) -> Option<String> {
.collect();
let mut seen_ids: HashSet<&str> = HashSet::with_capacity(filtered.len());
filtered.retain(|item| {
match item.get("id").and_then(Value::as_str) {
Some(id) => seen_ids.insert(id),
None => true,
}
filtered.retain(|item| match item.get("id").and_then(Value::as_str) {
Some(id) => seen_ids.insert(id),
None => true,
});
match sort_by {
"year" => {
filtered.sort_by(|a, b| {
let ya = a.get("releaseInfo").and_then(Value::as_str).and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
let yb = b.get("releaseInfo").and_then(Value::as_str).and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
if request.ascending { ya.cmp(&yb) } else { yb.cmp(&ya) }
let ya = a
.get("releaseInfo")
.and_then(Value::as_str)
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0);
let yb = b
.get("releaseInfo")
.and_then(Value::as_str)
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0);
if request.ascending {
ya.cmp(&yb)
} else {
yb.cmp(&ya)
}
});
}
"rating" => {
@ -411,7 +430,11 @@ pub(crate) fn discover_sort_plan_json(request_json: &str) -> Option<String> {
filtered.sort_by(|a, b| {
let na = a.get("name").and_then(Value::as_str).unwrap_or("");
let nb = b.get("name").and_then(Value::as_str).unwrap_or("");
if request.ascending { na.cmp(nb) } else { nb.cmp(na) }
if request.ascending {
na.cmp(nb)
} else {
nb.cmp(na)
}
});
}
_ => {}
@ -432,7 +455,11 @@ pub(crate) fn discover_sort_plan_json(request_json: &str) -> Option<String> {
pub(crate) fn library_sort_plan_json(request_json: &str) -> Option<String> {
let request = serde_json::from_str::<LibrarySortRequest>(request_json).ok()?;
let type_filter = request.type_filter.as_deref().unwrap_or("").to_lowercase();
let status_filter = request.status_filter.as_deref().unwrap_or("").to_lowercase();
let status_filter = request
.status_filter
.as_deref()
.unwrap_or("")
.to_lowercase();
let sort_by = request.sort_by.as_deref().unwrap_or("added");
let mut filtered: Vec<&Value> = request
@ -458,21 +485,41 @@ pub(crate) fn library_sort_plan_json(request_json: &str) -> Option<String> {
filtered.sort_by(|a, b| {
let na = a.get("name").and_then(Value::as_str).unwrap_or("");
let nb = b.get("name").and_then(Value::as_str).unwrap_or("");
if request.ascending { na.cmp(nb) } else { nb.cmp(na) }
if request.ascending {
na.cmp(nb)
} else {
nb.cmp(na)
}
});
}
"year" => {
filtered.sort_by(|a, b| {
let ya = a.get("releaseInfo").and_then(Value::as_str).and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
let yb = b.get("releaseInfo").and_then(Value::as_str).and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
if request.ascending { ya.cmp(&yb) } else { yb.cmp(&ya) }
let ya = a
.get("releaseInfo")
.and_then(Value::as_str)
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0);
let yb = b
.get("releaseInfo")
.and_then(Value::as_str)
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(0);
if request.ascending {
ya.cmp(&yb)
} else {
yb.cmp(&ya)
}
});
}
"progress" => {
filtered.sort_by(|a, b| {
let pa = a.get("timeOffset").and_then(Value::as_i64).unwrap_or(0);
let pb = b.get("timeOffset").and_then(Value::as_i64).unwrap_or(0);
if request.ascending { pa.cmp(&pb) } else { pb.cmp(&pa) }
if request.ascending {
pa.cmp(&pb)
} else {
pb.cmp(&pa)
}
});
}
_ => {}
@ -516,7 +563,10 @@ fn extract_imdb_id(raw: &str) -> Option<String> {
.take_while(|&&b| b.is_ascii_digit() || (b == b't' && start == 0))
.count();
let candidate = &raw[start..start + end];
if candidate.starts_with("tt") && candidate[2..].chars().all(|c| c.is_ascii_digit()) && candidate.len() > 3 {
if candidate.starts_with("tt")
&& candidate[2..].chars().all(|c| c.is_ascii_digit())
&& candidate.len() > 3
{
return Some(candidate.to_string());
}
}
@ -562,7 +612,10 @@ pub(crate) fn resolve_transport_url_json(source_json: &str, addons_json: &str) -
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_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() {
@ -571,22 +624,39 @@ pub(crate) fn resolve_transport_url_json(source_json: &str, addons_json: &str) -
other => other.to_string(),
}
};
let src_type = source.get("type").and_then(Value::as_str).map(normalize_type);
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("");
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())) {
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 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())
&& src_type.as_deref().is_none_or(|st| {
cat.get("type").and_then(Value::as_str).map(&normalize_type)
== Some(st.to_string())
})
});
if matches {
@ -599,12 +669,19 @@ pub(crate) fn resolve_transport_url_json(source_json: &str, addons_json: &str) -
/// 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> {
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()) {
if let Some(genre) = option
.get("genre")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
{
return Some(genre.to_string());
}
@ -612,24 +689,38 @@ pub(crate) fn resolve_feed_option_genre_json(feed_option_json: &str, addons_json
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 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 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)
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 })?;
let resolved = default_genre.or(if is_required { first_option } else { None })?;
Some(resolved.to_string())
}
@ -711,10 +802,8 @@ mod tests {
#[test]
fn season_load_plan_uses_saved_season_when_valid() {
let result: Value = serde_json::from_str(
&detail_season_load_plan_json(
r#"{"savedVideoId":"tt1:3:2","seasonsCount":5}"#,
)
.unwrap(),
&detail_season_load_plan_json(r#"{"savedVideoId":"tt1:3:2","seasonsCount":5}"#)
.unwrap(),
)
.unwrap();
assert_eq!(result["firstSeasonToLoad"], 3);
@ -722,10 +811,9 @@ mod tests {
#[test]
fn season_load_plan_defaults_to_season_1_when_no_saved() {
let result: Value = serde_json::from_str(
&detail_season_load_plan_json(r#"{"seasonsCount":5}"#).unwrap(),
)
.unwrap();
let result: Value =
serde_json::from_str(&detail_season_load_plan_json(r#"{"seasonsCount":5}"#).unwrap())
.unwrap();
assert_eq!(result["firstSeasonToLoad"], 1);
}
}

View file

@ -546,7 +546,12 @@ pub(crate) fn subtitle_language_matches(
let normalized_preference = normalize_language_preference(preferred_language);
let word_regex =
regex::Regex::new(&format!(r"\b{}\b", regex::escape(&normalized_preference))).ok();
subtitle_language_matches_precompiled(label, language, &normalized_preference, word_regex.as_ref())
subtitle_language_matches_precompiled(
label,
language,
&normalized_preference,
word_regex.as_ref(),
)
}
fn subtitle_language_matches_precompiled(
@ -586,20 +591,28 @@ fn find_preferred_subtitle_index_in_tracks(
.or_else(|| preferred_subtitle_language.filter(|value| *value != "none"));
if let Some(preferred) = primary {
let norm = normalize_language_preference(preferred);
let word_regex =
regex::Regex::new(&format!(r"\b{}\b", regex::escape(&norm))).ok();
let word_regex = regex::Regex::new(&format!(r"\b{}\b", regex::escape(&norm))).ok();
if let Some(index) = tracks.iter().position(|track| {
subtitle_language_matches_precompiled(&track.label, track.language.as_deref(), &norm, word_regex.as_ref())
subtitle_language_matches_precompiled(
&track.label,
track.language.as_deref(),
&norm,
word_regex.as_ref(),
)
}) {
return index as i32;
}
}
if let Some(secondary) = secondary_subtitle_language.filter(|value| *value != "none") {
let norm = normalize_language_preference(secondary);
let word_regex =
regex::Regex::new(&format!(r"\b{}\b", regex::escape(&norm))).ok();
let word_regex = regex::Regex::new(&format!(r"\b{}\b", regex::escape(&norm))).ok();
if let Some(index) = tracks.iter().position(|track| {
subtitle_language_matches_precompiled(&track.label, track.language.as_deref(), &norm, word_regex.as_ref())
subtitle_language_matches_precompiled(
&track.label,
track.language.as_deref(),
&norm,
word_regex.as_ref(),
)
}) {
return index as i32;
}
@ -739,12 +752,30 @@ fn stream_selection_item_from_value(v: &Value) -> StreamSelectionItem {
StreamSelectionItem {
name: v.get("name").and_then(Value::as_str).map(str::to_string),
title: v.get("title").and_then(Value::as_str).map(str::to_string),
description: v.get("description").and_then(Value::as_str).map(str::to_string),
addon_name: v.get("addonName").and_then(Value::as_str).map(str::to_string),
playable_url: v.get("playableUrl").and_then(Value::as_str).map(str::to_string),
binge_group: v.get("bingeGroup").and_then(Value::as_str).map(str::to_string),
filename: v.get("filename").and_then(Value::as_str).map(str::to_string),
effective_filename: v.get("effectiveFilename").and_then(Value::as_str).map(str::to_string),
description: v
.get("description")
.and_then(Value::as_str)
.map(str::to_string),
addon_name: v
.get("addonName")
.and_then(Value::as_str)
.map(str::to_string),
playable_url: v
.get("playableUrl")
.and_then(Value::as_str)
.map(str::to_string),
binge_group: v
.get("bingeGroup")
.and_then(Value::as_str)
.map(str::to_string),
filename: v
.get("filename")
.and_then(Value::as_str)
.map(str::to_string),
effective_filename: v
.get("effectiveFilename")
.and_then(Value::as_str)
.map(str::to_string),
}
}
@ -803,6 +834,7 @@ pub(crate) fn manual_stream_index(
.unwrap_or(-1)
}
#[allow(clippy::too_many_arguments)]
fn select_stream_index_inner(
streams: &[StreamSelectionItem],
current_video_id: &str,
@ -828,14 +860,28 @@ fn select_stream_index_inner(
match source_selection_mode {
STREAM_SOURCE_MODE_REGEX => {
let Some(pattern) = regex_pattern.filter(|value| !value.trim().is_empty()) else {
return manual_stream_index(streams, current_video_id, initial_stream_index, saved_url, saved_title);
return manual_stream_index(
streams,
current_video_id,
initial_stream_index,
saved_url,
saved_title,
);
};
let regex = match regex::RegexBuilder::new(pattern)
.case_insensitive(true)
.build()
{
Ok(regex) => regex,
Err(_) => return manual_stream_index(streams, current_video_id, initial_stream_index, saved_url, saved_title),
Err(_) => {
return manual_stream_index(
streams,
current_video_id,
initial_stream_index,
saved_url,
saved_title,
)
}
};
if let Some(index) = index_of_first_playable(streams, current_video_id, |stream| {
regex.is_match(&stream.selection_text())
@ -851,9 +897,16 @@ fn select_stream_index_inner(
_ => {}
}
manual_stream_index(streams, current_video_id, initial_stream_index, saved_url, saved_title)
manual_stream_index(
streams,
current_video_id,
initial_stream_index,
saved_url,
saved_title,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn select_stream_index(
streams_json: &str,
current_video_id: &str,
@ -867,9 +920,19 @@ pub(crate) fn select_stream_index(
let Ok(streams) = serde_json::from_str::<Vec<StreamSelectionItem>>(streams_json) else {
return -1;
};
select_stream_index_inner(&streams, current_video_id, initial_stream_index, saved_url, saved_title, source_selection_mode, regex_pattern, preferred_binge_group)
select_stream_index_inner(
&streams,
current_video_id,
initial_stream_index,
saved_url,
saved_title,
source_selection_mode,
regex_pattern,
preferred_binge_group,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn select_stream_index_values(
streams: &[Value],
current_video_id: &str,
@ -880,8 +943,20 @@ pub(crate) fn select_stream_index_values(
regex_pattern: Option<&str>,
preferred_binge_group: Option<&str>,
) -> i32 {
let items: Vec<StreamSelectionItem> = streams.iter().map(stream_selection_item_from_value).collect();
select_stream_index_inner(&items, current_video_id, initial_stream_index, saved_url, saved_title, source_selection_mode, regex_pattern, preferred_binge_group)
let items: Vec<StreamSelectionItem> = streams
.iter()
.map(stream_selection_item_from_value)
.collect();
select_stream_index_inner(
&items,
current_video_id,
initial_stream_index,
saved_url,
saved_title,
source_selection_mode,
regex_pattern,
preferred_binge_group,
)
}
#[cfg(test)]
@ -968,9 +1043,21 @@ mod tests {
#[test]
fn resolve_torrent_file_index_prefers_requested_then_filename_then_largest_video() {
let stats = vec![
TorrentFileStat { id: 1, path: "Show.S01E01.mkv".to_string(), length: 100 },
TorrentFileStat { id: 2, path: "Show.S01E02.mkv".to_string(), length: 300 },
TorrentFileStat { id: 3, path: "sample.txt".to_string(), length: 999_999 },
TorrentFileStat {
id: 1,
path: "Show.S01E01.mkv".to_string(),
length: 100,
},
TorrentFileStat {
id: 2,
path: "Show.S01E02.mkv".to_string(),
length: 300,
},
TorrentFileStat {
id: 3,
path: "sample.txt".to_string(),
length: 999_999,
},
];
// Addon-provided fileIdx wins outright, even though it doesn't match any stat.
@ -992,7 +1079,10 @@ mod tests {
(Some(2), Some("largest-video".to_string()))
);
assert_eq!(resolve_torrent_file_index("title", None, None, &[]), (None, None));
assert_eq!(
resolve_torrent_file_index("title", None, None, &[]),
(None, None)
);
}
#[test]

View file

@ -2,7 +2,11 @@ use crate::constants::DEFAULT_LANGUAGE;
use serde_json::{json, Value};
pub(crate) fn tmdb_content_type(content_type: &str) -> &str {
if content_type == "series" { "tv" } else { "movie" }
if content_type == "series" {
"tv"
} else {
"movie"
}
}
pub(crate) fn tmdb_language(language: &str) -> String {
@ -16,50 +20,92 @@ pub(crate) fn tmdb_language(language: &str) -> String {
pub(crate) fn tmdb_image_url(path: Option<&str>, size: &str) -> Option<String> {
let path = path?.trim();
if path.is_empty() { return None; }
if path.is_empty() {
return None;
}
Some(format!("https://image.tmdb.org/t/p/{size}{path}"))
}
pub(crate) fn tmdb_meta_to_meta_json(item_json: &str, requested_type: &str, language: &str) -> Option<String> {
pub(crate) fn tmdb_meta_to_meta_json(
item_json: &str,
requested_type: &str,
language: &str,
) -> Option<String> {
let item: Value = serde_json::from_str(item_json).ok()?;
let id = item.get("id").and_then(Value::as_i64)?;
let media_type = item.get("media_type").and_then(Value::as_str).unwrap_or("");
let has_tv = media_type == "tv" || item.get("first_air_date").is_some();
let content_type = if requested_type == "series" || has_tv { "series" } else { "movie" };
let name = item.get("title")
let content_type = if requested_type == "series" || has_tv {
"series"
} else {
"movie"
};
let name = item
.get("title")
.or_else(|| item.get("name"))
.or_else(|| item.get("original_name"))
.and_then(Value::as_str)
.unwrap_or(if language == "tr" { "Bilinmeyen" } else { "Unknown" });
let released = item.get("release_date").or_else(|| item.get("first_air_date"))
.unwrap_or(if language == "tr" {
"Bilinmeyen"
} else {
"Unknown"
});
let released = item
.get("release_date")
.or_else(|| item.get("first_air_date"))
.and_then(Value::as_str);
let poster = tmdb_image_url(item.get("poster_path").and_then(Value::as_str), "w500");
let background = tmdb_image_url(item.get("backdrop_path").and_then(Value::as_str), "original");
Some(serde_json::to_string(&json!({
let background = tmdb_image_url(
item.get("backdrop_path").and_then(Value::as_str),
"original",
);
serde_json::to_string(&json!({
"id": format!("tmdb:{id}"),
"type": content_type,
"name": name,
"poster": poster,
"background": background,
"releaseInfo": released.map(|r| r.get(..4).unwrap_or(r)),
})).ok()?)
}))
.ok()
}
pub(crate) fn tmdb_video_to_trailer_json(video_json: &str) -> Option<String> {
let video: Value = serde_json::from_str(video_json).ok()?;
let site = video.get("site").and_then(Value::as_str).unwrap_or("").to_lowercase();
if site != "youtube" { return None; }
let key = video.get("key").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty())?;
let video_type = video.get("type").and_then(Value::as_str).map(str::trim).unwrap_or("Trailer");
let site = video
.get("site")
.and_then(Value::as_str)
.unwrap_or("")
.to_lowercase();
if site != "youtube" {
return None;
}
let key = video
.get("key")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())?;
let video_type = video
.get("type")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or("Trailer");
let type_lower = video_type.to_lowercase();
if !["trailer", "teaser", "clip"].contains(&type_lower.as_str()) { return None; }
let title = video.get("name").and_then(Value::as_str).map(str::trim)
.filter(|s| !s.is_empty()).unwrap_or(video_type);
Some(serde_json::to_string(&json!({
if !["trailer", "teaser", "clip"].contains(&type_lower.as_str()) {
return None;
}
let title = video
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(video_type);
serde_json::to_string(&json!({
"url": format!("https://www.youtube.com/watch?v={key}"),
"title": title,
"type": video_type,
})).ok()?)
}))
.ok()
}
pub(crate) fn tmdb_bulk_metas_to_metas_json(
@ -68,7 +114,8 @@ pub(crate) fn tmdb_bulk_metas_to_metas_json(
language: &str,
) -> Option<String> {
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
let metas: Vec<Value> = items.iter()
let metas: Vec<Value> = items
.iter()
.filter_map(|item| {
let s = serde_json::to_string(item).ok()?;
let meta_json = tmdb_meta_to_meta_json(&s, requested_type, language)?;
@ -80,7 +127,8 @@ pub(crate) fn tmdb_bulk_metas_to_metas_json(
pub(crate) fn tmdb_bulk_videos_to_trailers_json(items_json: &str) -> Option<String> {
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
let trailers: Vec<Value> = items.iter()
let trailers: Vec<Value> = items
.iter()
.filter_map(|item| {
let s = serde_json::to_string(item).ok()?;
let json = tmdb_video_to_trailer_json(&s)?;

View file

@ -65,7 +65,11 @@ pub(crate) fn library_external_merge_plan_json(request_json: &str) -> Option<Str
let local_ids: std::collections::HashSet<String> = request
.local_items
.iter()
.filter_map(|item| item.get("id").and_then(Value::as_str).map(ToString::to_string))
.filter_map(|item| {
item.get("id")
.and_then(Value::as_str)
.map(ToString::to_string)
})
.collect();
let merged_external: Vec<&Value> = request
.external_items
@ -92,7 +96,11 @@ pub(crate) fn library_collection_import_validation_json(request_json: &str) -> O
let mut valid_collections = Vec::<Value>::new();
for (i, col) in request.collections.iter().enumerate() {
let id = col.get("id").and_then(Value::as_str).unwrap_or("").trim();
let title = col.get("title").and_then(Value::as_str).unwrap_or("").trim();
let title = col
.get("title")
.and_then(Value::as_str)
.unwrap_or("")
.trim();
if id.is_empty() {
issues.push(format!("collection[{}]: missing id", i));
continue;
@ -141,8 +149,7 @@ pub(crate) fn playback_progress_merge_plan_json(request_json: &str) -> Option<St
let existing_video_id = existing.get("lastVideoId").and_then(Value::as_str);
let incoming_video_id = incoming.get("lastVideoId").and_then(Value::as_str);
let video_changed =
incoming_video_id.is_some() && incoming_video_id != existing_video_id;
let video_changed = incoming_video_id.is_some() && incoming_video_id != existing_video_id;
let resolve_field = |key: &str| -> Value {
incoming
@ -154,7 +161,10 @@ pub(crate) fn playback_progress_merge_plan_json(request_json: &str) -> Option<St
};
let last_episode_name = if video_changed {
incoming.get("lastEpisodeName").cloned().unwrap_or(Value::Null)
incoming
.get("lastEpisodeName")
.cloned()
.unwrap_or(Value::Null)
} else {
resolve_field("lastEpisodeName")
};
@ -187,18 +197,30 @@ pub(crate) fn playback_progress_merge_plan_json(request_json: &str) -> Option<St
}
fn cleaned_url(raw: Option<&str>) -> Option<String> {
raw.map(str::trim).filter(|s| !s.is_empty()).map(str::to_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])
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
};
@ -206,7 +228,11 @@ fn cleaned_artwork_url(raw: Option<&str>) -> Option<String> {
}
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()); } }
for k in keys {
if let Some(Value::String(s)) = obj.get(*k) {
return Some(s.as_str());
}
}
None
}
@ -332,8 +358,7 @@ pub(crate) fn export_collections_json(collections_json: &str) -> Option<String>
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())
.filter(|arr| !arr.is_empty()).cloned()
.unwrap_or_else(|| {
if let Some(cid) = folder.get("catalogId").and_then(Value::as_str) {
vec![json!({ "catalogId": cid, "type": "movie" })]
@ -459,8 +484,13 @@ mod tests {
}
}
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};
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()?;
@ -468,22 +498,36 @@ pub(crate) fn library_apply_mark_watched_json(lib_json: &str, video_ids_json: &s
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() {
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("");
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 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("");
let last_vid = entry
.get("lastVideoId")
.and_then(Value::as_str)
.unwrap_or("");
last_vid.is_empty() || !watched.contains(last_vid)
})
.collect();
@ -499,7 +543,10 @@ pub(crate) fn library_apply_mark_watched_json(lib_json: &str, video_ids_json: &s
serde_json::to_string(&Value::Object(lib)).ok()
}
pub(crate) fn merge_progress_meta_json(incoming_meta_json: &str, existing_meta_json: &str) -> String {
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!({}));