mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-08-04 07:56:00 +00:00
feat: update desktop core API support
This commit is contained in:
parent
5fb580f782
commit
b0bb52a6a3
55 changed files with 7190 additions and 2099 deletions
2658
Cargo.lock
generated
2658
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
49
Cargo.toml
49
Cargo.toml
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<'_>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"] }],
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
2190
src/bindings/jni.rs
2190
src/bindings/jni.rs
File diff suppressed because it is too large
Load diff
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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('&', "&").replace('<', "<").replace('>', ">")
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
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>"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
107
src/core_api.rs
107
src/core_api.rs
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
369
src/ffi.rs
369
src/ffi.rs
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
54
src/lib.rs
54
src/lib.rs
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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!({}));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue