mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-08-09 16:37:31 +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]
|
[package]
|
||||||
name = "fluxa_core"
|
name = "fluxa_core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -14,37 +33,27 @@ required-features = ["uniffi-cli"]
|
||||||
[features]
|
[features]
|
||||||
default = ["native"]
|
default = ["native"]
|
||||||
native = [
|
native = [
|
||||||
|
"full-api",
|
||||||
"dep:jni",
|
"dep:jni",
|
||||||
"dep:dolby_vision",
|
"dep:dolby_vision",
|
||||||
"uniffi-bindings",
|
"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"]
|
uniffi-cli = ["uniffi-bindings", "uniffi/cli"]
|
||||||
wasm = ["dep:wasm-bindgen", "chrono/wasmbind"]
|
wasm = ["full-api", "dep:wasm-bindgen", "chrono/wasmbind"]
|
||||||
fuzzing = []
|
fuzzing = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
chrono = { version = "0.4.45", features = ["serde"] }
|
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 }
|
dolby_vision = { workspace = true, optional = true }
|
||||||
jni = { version = "0.21", optional = true }
|
jni = { workspace = true, optional = true }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { workspace = true }
|
||||||
serde_json = "1.0"
|
serde_json = { workspace = true }
|
||||||
uniffi = { version = "0.31.1", optional = true }
|
uniffi = { version = "0.31.1", optional = true }
|
||||||
wasm-bindgen = { version = "0.2", 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"
|
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 |
|
| 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-bindings` | UniFFI runtime support (pulled in by `native`) |
|
||||||
| `uniffi-cli` | Adds the `uniffi-bindgen` binary for generating Kotlin/Swift source |
|
| `uniffi-cli` | Adds the `uniffi-bindgen` binary for generating Kotlin/Swift source |
|
||||||
| `wasm` | `wasm-bindgen` exports for webOS |
|
| `wasm` | `wasm-bindgen` exports for webOS |
|
||||||
|
|
@ -22,6 +25,9 @@ cargo test --lib
|
||||||
# check the webOS/WASM path compiles
|
# check the webOS/WASM path compiles
|
||||||
cargo check --no-default-features --features wasm
|
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
|
# generate UniFFI Kotlin bindings
|
||||||
cargo run --bin uniffi-bindgen --features uniffi-cli -- generate \
|
cargo run --bin uniffi-bindgen --features uniffi-cli -- generate \
|
||||||
--library target/debug/libfluxa_core.so \
|
--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`.
|
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
|
## 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 torrent_serve # local torrent HTTP proxy
|
||||||
cargo build --bin companion_server # fluxa-web's local companion process
|
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]
|
[dependencies]
|
||||||
axum = { version = "0.8", optional = true }
|
axum = { version = "0.8", optional = true }
|
||||||
tower-http = { version = "0.6", features = ["cors"], 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 }
|
dolby_vision = { workspace = true }
|
||||||
fluxa_core = { path = "..", default-features = false, optional = true }
|
fluxa_core = { workspace = true, features = ["streaming-shared"], optional = true }
|
||||||
jni = { version = "0.21", optional = true }
|
jni = { workspace = true, optional = true }
|
||||||
librqbit = { version = "8.1.1", default-features = false, features = ["rust-tls", "disable-upload"], 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 }
|
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "form", "rustls"], optional = true }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { workspace = true }
|
||||||
serde_json = "1.0"
|
serde_json = { workspace = true }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "io-util", "sync", "process", "fs", "macros"], optional = 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 }
|
tokio-util = { version = "0.7", features = ["io"], optional = true }
|
||||||
url = { version = "2", 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::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::local_stream::{start_local_stream_server, stop_local_stream_server};
|
||||||
use crate::torrent_engine;
|
use crate::torrent_engine;
|
||||||
use jni::objects::{JByteArray, JClass, JString};
|
use jni::objects::{JByteArray, JClass, JString};
|
||||||
|
|
@ -24,6 +24,9 @@ fn write_jstring(env: &mut JNIEnv<'_>, value: Option<String>) -> JStringReturn {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[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(
|
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_startLocalStreamServerNative(
|
||||||
mut env: JNIEnv<'_>,
|
mut env: JNIEnv<'_>,
|
||||||
_class: JObject<'_>,
|
_class: JObject<'_>,
|
||||||
|
|
@ -47,6 +50,9 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_
|
||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[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(
|
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_startDvRewriteLocalStreamServerNative(
|
||||||
mut env: JNIEnv<'_>,
|
mut env: JNIEnv<'_>,
|
||||||
_class: JObject<'_>,
|
_class: JObject<'_>,
|
||||||
|
|
@ -70,6 +76,9 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_
|
||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[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(
|
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_stopLocalStreamServerNative(
|
||||||
mut env: JNIEnv<'_>,
|
mut env: JNIEnv<'_>,
|
||||||
_class: JObject<'_>,
|
_class: JObject<'_>,
|
||||||
|
|
@ -85,21 +94,29 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_
|
||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[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(
|
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_startTorrentServerNative(
|
||||||
mut env: JNIEnv<'_>,
|
mut env: JNIEnv<'_>,
|
||||||
_class: JObject<'_>,
|
_class: JObject<'_>,
|
||||||
cache_dir: JString<'_>,
|
cache_dir: JString<'_>,
|
||||||
preferred_port: JInt,
|
preferred_port: JInt,
|
||||||
|
access_token: JString<'_>,
|
||||||
) -> JStringReturn {
|
) -> JStringReturn {
|
||||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
let output = read_jstring(&mut env, &cache_dir)
|
let output = read_jstring(&mut env, &cache_dir).and_then(|cache_dir| {
|
||||||
.and_then(|cache_dir| torrent_engine::start_torrent_server(&cache_dir, preferred_port));
|
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)
|
write_jstring(&mut env, output)
|
||||||
}))
|
}))
|
||||||
.unwrap_or(ptr::null_mut())
|
.unwrap_or(ptr::null_mut())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[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(
|
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_stopTorrentServerNative(
|
||||||
_env: JNIEnv<'_>,
|
_env: JNIEnv<'_>,
|
||||||
_class: JObject<'_>,
|
_class: JObject<'_>,
|
||||||
|
|
@ -111,6 +128,8 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_
|
||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[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(
|
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_dvRpuSelfTestNative(
|
||||||
_env: JNIEnv<'_>,
|
_env: JNIEnv<'_>,
|
||||||
_class: JObject<'_>,
|
_class: JObject<'_>,
|
||||||
|
|
@ -122,6 +141,8 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_
|
||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[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(
|
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_dvAutoDetectWasIptPqc2Native(
|
||||||
_env: JNIEnv<'_>,
|
_env: JNIEnv<'_>,
|
||||||
_class: JObject<'_>,
|
_class: JObject<'_>,
|
||||||
|
|
@ -133,6 +154,9 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_
|
||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[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(
|
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_dvRewriteSegmentBytesNative(
|
||||||
env: JNIEnv<'_>,
|
env: JNIEnv<'_>,
|
||||||
_class: JObject<'_>,
|
_class: JObject<'_>,
|
||||||
|
|
@ -178,6 +202,8 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_
|
||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[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(
|
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_dvGetStreamStatsJsonNative(
|
||||||
mut env: JNIEnv<'_>,
|
mut env: JNIEnv<'_>,
|
||||||
_class: JObject<'_>,
|
_class: JObject<'_>,
|
||||||
|
|
@ -189,6 +215,22 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_
|
||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[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(
|
pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaStreamingNative_parseMkvChaptersNative(
|
||||||
mut env: JNIEnv<'_>,
|
mut env: JNIEnv<'_>,
|
||||||
_class: JObject<'_>,
|
_class: JObject<'_>,
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@
|
||||||
/// Tauri commands in fluxa-desktop/src-tauri/src/lib.rs and oauth.rs, just
|
/// 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
|
/// exposed over HTTP instead of IPC. Used by both the standalone
|
||||||
/// `companion_server` binary and the `fluxa-companion` tray app.
|
/// `companion_server` binary and the `fluxa-companion` tray app.
|
||||||
use axum::extract::State;
|
use axum::extract::{Request, State};
|
||||||
use axum::http::{HeaderValue, Method, StatusCode};
|
use axum::http::{header, HeaderMap, HeaderValue, Method, StatusCode};
|
||||||
|
use axum::middleware::{self, Next};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
|
|
@ -34,6 +35,45 @@ async fn health() -> &'static str {
|
||||||
"ok"
|
"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(
|
async fn start_torrent(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(body): Json<StartTorrentBody>,
|
Json(body): Json<StartTorrentBody>,
|
||||||
|
|
@ -50,7 +90,12 @@ async fn start_torrent_inner(state: &AppState, body: StartTorrentBody) -> Result
|
||||||
Some(url) => url.clone(),
|
Some(url) => url.clone(),
|
||||||
None => {
|
None => {
|
||||||
let cache_dir = std::env::temp_dir().join("fluxa-web-torrent-cache");
|
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())?;
|
.ok_or_else(|| "failed to start torrent server".to_string())?;
|
||||||
let server: Value = serde_json::from_str(&server_json)
|
let server: Value = serde_json::from_str(&server_json)
|
||||||
.map_err(|e| format!("invalid torrent server response: {e}"))?;
|
.map_err(|e| format!("invalid torrent server response: {e}"))?;
|
||||||
|
|
@ -157,8 +202,12 @@ fn cors_layer() -> CorsLayer {
|
||||||
|
|
||||||
CorsLayer::new()
|
CorsLayer::new()
|
||||||
.allow_origin(origins)
|
.allow_origin(origins)
|
||||||
.allow_methods([Method::GET, Method::POST])
|
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
||||||
.allow_headers([axum::http::header::CONTENT_TYPE])
|
.allow_headers([
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
header::AUTHORIZATION,
|
||||||
|
axum::http::HeaderName::from_static("x-fluxa-companion-token"),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router() -> Router {
|
pub fn router() -> Router {
|
||||||
|
|
@ -170,6 +219,7 @@ pub fn router() -> Router {
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
.merge(crate::transcode::router())
|
.merge(crate::transcode::router())
|
||||||
.merge(crate::oauth_proxy::router())
|
.merge(crate::oauth_proxy::router())
|
||||||
|
.layer(middleware::from_fn(require_companion_token))
|
||||||
.layer(cors_layer())
|
.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}");
|
eprintln!("[companion-server] listening on http://127.0.0.1:{port}");
|
||||||
axum::serve(listener, router()).await
|
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::dovi_rpu::DoviRpu;
|
||||||
|
use dolby_vision::rpu::extension_metadata::blocks::ExtMetadataBlock;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
// Startup self-test
|
// 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.
|
// 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.
|
// Read by Kotlin in onVideoInputFormatChanged to activate the IPTPQc2 → SDR shader.
|
||||||
static DV_LAST_AUTO_DETECT_IPTPQC2: AtomicBool = AtomicBool::new(false);
|
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 {
|
pub(crate) fn dv_auto_detect_was_iptpqc2() -> bool {
|
||||||
DV_LAST_AUTO_DETECT_IPTPQC2.load(Ordering::Relaxed)
|
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
|
// Synchronous byte-buffer segment rewriter
|
||||||
//
|
//
|
||||||
// Used by the Kotlin OkHttp interceptor to convert HLS segments (fMP4 .m4s or
|
// 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::collections::HashMap;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::{TcpListener, TcpStream};
|
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::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::local_stream::{
|
use crate::local_stream::{
|
||||||
build_proxy_client, local_stream_servers, parse_request, send_upstream_request,
|
build_proxy_client, local_stream_servers, next_local_stream_id, parse_request,
|
||||||
write_simple_response, LocalStreamConfig, LocalStreamHandle, LOCAL_STREAM_ID,
|
send_upstream_request, write_simple_response, LocalStreamConfig, LocalStreamHandle,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Public config
|
// 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();
|
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 dv_config = Arc::new(serde_json::from_str::<DvRewriteConfig>(dv_config_json).ok()?);
|
||||||
|
|
||||||
let id = LOCAL_STREAM_ID
|
let id = next_local_stream_id();
|
||||||
.fetch_add(1, Ordering::Relaxed)
|
|
||||||
.to_string();
|
|
||||||
let bind_port = preferred_port.clamp(0, u16::MAX as i32) as u16;
|
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();
|
let port = listener.local_addr().ok()?.port();
|
||||||
listener.set_nonblocking(true).ok()?;
|
listener.set_nonblocking(true).ok()?;
|
||||||
|
|
||||||
|
|
@ -178,6 +202,8 @@ pub(crate) fn start_dv_rewrite_local_stream_server(
|
||||||
target_url: target_url.to_string(),
|
target_url: target_url.to_string(),
|
||||||
headers,
|
headers,
|
||||||
client: Arc::new(build_proxy_client()),
|
client: Arc::new(build_proxy_client()),
|
||||||
|
active_connections: Arc::new(AtomicUsize::new(0)),
|
||||||
|
port,
|
||||||
};
|
};
|
||||||
|
|
||||||
let thread = thread::spawn(move || {
|
let thread = thread::spawn(move || {
|
||||||
|
|
@ -209,7 +235,6 @@ pub(crate) fn start_dv_rewrite_local_stream_server(
|
||||||
.ok()
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-connection handler
|
|
||||||
fn handle_dv_stream(mut stream: TcpStream, config: LocalStreamConfig, dv: &DvRewriteConfig) {
|
fn handle_dv_stream(mut stream: TcpStream, config: LocalStreamConfig, dv: &DvRewriteConfig) {
|
||||||
let Some(request) = parse_request(&mut stream) else {
|
let Some(request) = parse_request(&mut stream) else {
|
||||||
write_simple_response(&mut stream, "400 Bad Request");
|
write_simple_response(&mut stream, "400 Bad Request");
|
||||||
|
|
@ -224,6 +249,11 @@ fn handle_dv_stream(mut stream: TcpStream, config: LocalStreamConfig, dv: &DvRew
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if dv.action == "hls_rpu_convert" {
|
||||||
|
handle_hls_rpu_convert(stream, config, dv, &request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let mut response =
|
let mut response =
|
||||||
match send_upstream_request(&config.client, &config, &request.method, &request.headers) {
|
match send_upstream_request(&config.client, &config, &request.method, &request.headers) {
|
||||||
Ok(r) => r,
|
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)
|
// DVCC strip (MKV / MP4 container)
|
||||||
//
|
//
|
||||||
// Searches the first 64 KiB of the stream for the DVCC or DVHE ISO-BMFF box
|
// 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>> {
|
fn convert_rpu_nal(nal: &[u8], mode: u8, zero_level5: bool) -> Option<Vec<u8>> {
|
||||||
let mut rpu = DoviRpu::parse_unspec62_nalu(nal).ok()?;
|
let mut rpu = DoviRpu::parse_unspec62_nalu(nal).ok()?;
|
||||||
|
store_l1_from_rpu(&rpu);
|
||||||
rpu.convert_with_mode(mode).ok()?;
|
rpu.convert_with_mode(mode).ok()?;
|
||||||
if zero_level5 {
|
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();
|
let _ = rpu.crop();
|
||||||
}
|
}
|
||||||
rpu.write_hevc_unspec62_nalu().ok()
|
rpu.write_hevc_unspec62_nalu().ok()
|
||||||
|
|
|
||||||
|
|
@ -13,16 +13,13 @@ fn platform_dir() -> &'static str {
|
||||||
fn bundled_path(name: &str) -> Option<PathBuf> {
|
fn bundled_path(name: &str) -> Option<PathBuf> {
|
||||||
let exe_dir = std::env::current_exe().ok()?.parent()?.to_path_buf();
|
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() };
|
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),
|
exe_dir.join("resources/ffmpeg").join(platform_dir()).join(&exe_name),
|
||||||
// cargo run / cargo test layout: target/<profile>/ -> crate root/resources
|
// cargo run / cargo test layout: target/<profile>/ -> crate root/resources
|
||||||
exe_dir.join("../../resources/ffmpeg").join(platform_dir()).join(&exe_name),
|
exe_dir.join("../../resources/ffmpeg").join(platform_dir()).join(&exe_name),
|
||||||
] {
|
]
|
||||||
if candidate.is_file() {
|
.into_iter()
|
||||||
return Some(candidate);
|
.find(|candidate| candidate.is_file())
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the ffmpeg/ffprobe binary to run: the bundled static build next to
|
/// Resolves the ffmpeg/ffprobe binary to run: the bundled static build next to
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::net::{TcpListener, TcpStream};
|
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::sync::{Arc, Mutex, OnceLock};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
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 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 {
|
pub(crate) fn build_proxy_client() -> reqwest::blocking::Client {
|
||||||
reqwest::blocking::Client::builder()
|
reqwest::blocking::Client::builder()
|
||||||
|
|
@ -25,6 +28,8 @@ pub(crate) struct LocalStreamConfig {
|
||||||
pub(crate) target_url: String,
|
pub(crate) target_url: String,
|
||||||
pub(crate) headers: HashMap<String, String>,
|
pub(crate) headers: HashMap<String, String>,
|
||||||
pub(crate) client: Arc<reqwest::blocking::Client>,
|
pub(crate) client: Arc<reqwest::blocking::Client>,
|
||||||
|
pub(crate) active_connections: Arc<AtomicUsize>,
|
||||||
|
pub(crate) port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct LocalStreamHandle {
|
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()))
|
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> {
|
pub(crate) fn parse_request(stream: &mut TcpStream) -> Option<ParsedLocalRequest> {
|
||||||
let mut reader = BufReader::new(stream.try_clone().ok()?);
|
let mut reader = BufReader::new(stream.try_clone().ok()?);
|
||||||
let mut request_line = String::new();
|
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) {
|
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 {
|
let Some(request) = parse_request(&mut stream) else {
|
||||||
write_simple_response(&mut stream, "400 Bad Request");
|
write_simple_response(&mut stream, "400 Bad Request");
|
||||||
return;
|
return;
|
||||||
|
|
@ -178,9 +224,9 @@ pub(crate) fn start_local_stream_server(
|
||||||
preferred_port: i32,
|
preferred_port: i32,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let headers = serde_json::from_str::<HashMap<String, String>>(headers_json).unwrap_or_default();
|
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 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();
|
let port = listener.local_addr().ok()?.port();
|
||||||
listener.set_nonblocking(true).ok()?;
|
listener.set_nonblocking(true).ok()?;
|
||||||
|
|
||||||
|
|
@ -191,6 +237,8 @@ pub(crate) fn start_local_stream_server(
|
||||||
target_url: target_url.to_string(),
|
target_url: target_url.to_string(),
|
||||||
headers,
|
headers,
|
||||||
client: Arc::new(build_proxy_client()),
|
client: Arc::new(build_proxy_client()),
|
||||||
|
active_connections: Arc::new(AtomicUsize::new(0)),
|
||||||
|
port,
|
||||||
};
|
};
|
||||||
let thread = thread::spawn(move || {
|
let thread = thread::spawn(move || {
|
||||||
while !thread_stop.load(Ordering::Relaxed) {
|
while !thread_stop.load(Ordering::Relaxed) {
|
||||||
|
|
@ -235,3 +283,25 @@ pub(crate) fn stop_local_stream_server(id: &str) -> bool {
|
||||||
}
|
}
|
||||||
true
|
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::body::Body;
|
||||||
use axum::extract::{Query, State};
|
use axum::extract::{connect_info::ConnectInfo, Query, State};
|
||||||
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
use axum::http::{HeaderMap, HeaderValue, StatusCode};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
|
|
@ -13,11 +13,12 @@ use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::io::SeekFrom;
|
use std::io::SeekFrom;
|
||||||
|
use std::net::SocketAddr;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::io::AsyncSeekExt;
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
use tokio::sync::Mutex as AsyncMutex;
|
use tokio::sync::Mutex as AsyncMutex;
|
||||||
|
|
@ -48,6 +49,7 @@ struct StreamQuery {
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
index: Option<usize>,
|
index: Option<usize>,
|
||||||
stat: Option<String>,
|
stat: Option<String>,
|
||||||
|
access_token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -56,6 +58,7 @@ struct EngineState {
|
||||||
output_dir: PathBuf,
|
output_dir: PathBuf,
|
||||||
preload_size: Arc<Mutex<u64>>,
|
preload_size: Arc<Mutex<u64>>,
|
||||||
known_links: Arc<Mutex<HashMap<String, usize>>>,
|
known_links: Arc<Mutex<HashMap<String, usize>>>,
|
||||||
|
access_token: Arc<String>,
|
||||||
// Serializes the check-then-add sequence in ensure_torrent so two
|
// Serializes the check-then-add sequence in ensure_torrent so two
|
||||||
// near-simultaneous requests for the same new link (e.g. a stat poll
|
// 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.
|
// 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))
|
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
|
// Stop any existing server first. The mutex is the single source of truth
|
||||||
// for whether the server is running — no separate AtomicBool needed.
|
// 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 (stop_tx, stop_rx) = oneshot::channel::<()>();
|
||||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(), String>>();
|
||||||
let thread_cache_dir = cache_dir.clone();
|
let thread_cache_dir = cache_dir.clone();
|
||||||
|
let thread_access_token = access_token.trim().to_string();
|
||||||
|
|
||||||
let thread = thread::spawn(move || {
|
let thread = thread::spawn(move || {
|
||||||
let worker_threads = std::thread::available_parallelism()
|
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();
|
let options = SessionOptions {
|
||||||
options.disable_dht_persistence = true;
|
disable_dht_persistence: true,
|
||||||
options.defer_writes_up_to = Some(64);
|
defer_writes_up_to: Some(64),
|
||||||
options.listen_port_range = Some(49152..65535);
|
listen_port_range: Some(49152..65535),
|
||||||
options.disable_upload = true;
|
disable_upload: true,
|
||||||
options.concurrent_init_limit = Some(2);
|
concurrent_init_limit: Some(2),
|
||||||
options.trackers = [
|
trackers: [
|
||||||
"udp://tracker.opentrackr.org:1337/announce",
|
"udp://tracker.opentrackr.org:1337/announce",
|
||||||
"udp://open.demonii.com:1337/announce",
|
"udp://open.demonii.com:1337/announce",
|
||||||
"udp://tracker.openbittorrent.com:80/announce",
|
"udp://tracker.openbittorrent.com:80/announce",
|
||||||
"udp://exodus.desync.com:6969/announce",
|
"udp://exodus.desync.com:6969/announce",
|
||||||
"udp://open.stealth.si:80/announce",
|
"udp://open.stealth.si:80/announce",
|
||||||
"udp://tracker.torrent.eu.org:451/announce",
|
"udp://tracker.torrent.eu.org:451/announce",
|
||||||
"udp://tracker.tiny-vps.com:6969/announce",
|
"udp://tracker.tiny-vps.com:6969/announce",
|
||||||
]
|
]
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|s| s.parse().ok())
|
.filter_map(|s| s.parse().ok())
|
||||||
.collect();
|
.collect(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
let session = match Session::new_with_opts(thread_cache_dir.clone(), options).await {
|
let session = match Session::new_with_opts(thread_cache_dir.clone(), options).await {
|
||||||
Ok(session) => session,
|
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,
|
output_dir: thread_cache_dir,
|
||||||
preload_size: Arc::new(Mutex::new(10 * 1024 * 1024)),
|
preload_size: Arc::new(Mutex::new(10 * 1024 * 1024)),
|
||||||
known_links: Arc::new(Mutex::new(HashMap::new())),
|
known_links: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
access_token: Arc::new(thread_access_token),
|
||||||
add_lock: Arc::new(AsyncMutex::new(())),
|
add_lock: Arc::new(AsyncMutex::new(())),
|
||||||
};
|
};
|
||||||
let app = Router::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))
|
.route("/stream/fname", get(stream_fname))
|
||||||
.with_state(state);
|
.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 _ = stop_rx.await;
|
||||||
});
|
});
|
||||||
let _ = ready_tx.send(Ok(()));
|
let _ = ready_tx.send(Ok(()));
|
||||||
|
|
@ -212,17 +233,28 @@ async fn root() -> impl IntoResponse {
|
||||||
|
|
||||||
async fn update_settings(
|
async fn update_settings(
|
||||||
State(state): State<EngineState>,
|
State(state): State<EngineState>,
|
||||||
|
ConnectInfo(remote_addr): ConnectInfo<SocketAddr>,
|
||||||
Json(settings): Json<TorrSettings>,
|
Json(settings): Json<TorrSettings>,
|
||||||
) -> impl IntoResponse {
|
) -> 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 Some(preload_mb) = settings.preload_size {
|
||||||
if let Ok(mut preload_size) = state.preload_size.lock() {
|
if let Ok(mut preload_size) = state.preload_size.lock() {
|
||||||
*preload_size = preload_mb.saturating_mul(1024 * 1024);
|
*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 _ = request.save_to_db;
|
||||||
let action = request.action.to_ascii_lowercase();
|
let action = request.action.to_ascii_lowercase();
|
||||||
match action.as_str() {
|
match action.as_str() {
|
||||||
|
|
@ -278,9 +310,13 @@ async fn stream_fname(
|
||||||
State(state): State<EngineState>,
|
State(state): State<EngineState>,
|
||||||
Query(query): Query<StreamQuery>,
|
Query(query): Query<StreamQuery>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
ConnectInfo(remote_addr): ConnectInfo<SocketAddr>,
|
||||||
) -> Response {
|
) -> 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");
|
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)
|
// Stat requests return immediately — no retry loop (used by Kotlin status polling)
|
||||||
if query.stat.is_some() {
|
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 {
|
let (id, details) = match ensure_torrent(&state, Some(&query.link), query.title.as_deref(), query.index).await {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
eprintln!("[TorrServer] ensure_torrent failed: {error}");
|
debug_log(format!("[TorrServer] ensure_torrent failed: {error}"));
|
||||||
return error_response(StatusCode::SERVICE_UNAVAILABLE, error);
|
return error_response(StatusCode::SERVICE_UNAVAILABLE, error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let file_id = query
|
let file_id = query
|
||||||
.index
|
.index
|
||||||
.unwrap_or_else(|| largest_file_id(&details).unwrap_or(0));
|
.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;
|
prioritize_stream_file(&state, id, file_id).await;
|
||||||
|
|
||||||
// Wait for rqbit to leave Initializing state before attempting to stream.
|
// Wait for rqbit to leave Initializing state before attempting to stream.
|
||||||
|
|
@ -321,7 +357,7 @@ async fn stream_fname(
|
||||||
)
|
)
|
||||||
.await
|
.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");
|
return error_response(StatusCode::SERVICE_UNAVAILABLE, "torrent init timed out");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -337,28 +373,29 @@ async fn stream_fname(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let total_len = stream.len();
|
let total_len = stream.len();
|
||||||
if let Some((start, end)) = parse_range(headers.get("Range"), total_len) {
|
match parse_range(headers.get("Range"), total_len) {
|
||||||
match stream.seek(SeekFrom::Start(start)).await {
|
Ok(Some((start, end))) => {
|
||||||
Ok(_) => {
|
if let Err(error) = stream.seek(SeekFrom::Start(start)).await {
|
||||||
status = StatusCode::PARTIAL_CONTENT;
|
debug_log(format!("[TorrServer] seek failed torrent={id} file={file_id} start={start} len={total_len}: {error}"));
|
||||||
let end = end.unwrap_or_else(|| total_len.saturating_sub(1));
|
return error_response(StatusCode::INTERNAL_SERVER_ERROR, "failed to seek stream");
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
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 {
|
Ok(None) => {
|
||||||
insert_header(&mut output_headers, "Content-Length", total_len.to_string());
|
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) => {
|
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:#}"))
|
error_response(StatusCode::NOT_FOUND, format!("{e:#}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -395,14 +432,16 @@ async fn ensure_torrent(
|
||||||
return Ok((id, details));
|
return Ok((id, details));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut options = AddTorrentOptions::default();
|
let mut options = AddTorrentOptions {
|
||||||
options.overwrite = true;
|
overwrite: true,
|
||||||
options.output_folder = Some(state.output_dir.to_string_lossy().into_owned());
|
output_folder: Some(state.output_dir.to_string_lossy().into_owned()),
|
||||||
options.peer_opts = Some(PeerConnectionOptions {
|
peer_opts: Some(PeerConnectionOptions {
|
||||||
connect_timeout: Some(Duration::from_secs(5)),
|
connect_timeout: Some(Duration::from_secs(5)),
|
||||||
read_write_timeout: Some(Duration::from_secs(20)),
|
read_write_timeout: Some(Duration::from_secs(20)),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
};
|
||||||
// Limit rqbit initialization to just the target file so the Initializing
|
// Limit rqbit initialization to just the target file so the Initializing
|
||||||
// hash-check covers one file instead of every file in the torrent.
|
// hash-check covers one file instead of every file in the torrent.
|
||||||
if let Some(file_id) = only_file {
|
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>)> {
|
fn parse_range(value: Option<&HeaderValue>, length: u64) -> Result<Option<(u64, u64)>, ()> {
|
||||||
let raw = value?.to_str().ok()?.strip_prefix("bytes=")?;
|
let Some(value) = value else {
|
||||||
let (start, end) = raw.split_once('-')?;
|
return Ok(None);
|
||||||
let start = start.parse::<u64>().ok()?;
|
};
|
||||||
if start >= length {
|
let raw = value.to_str().map_err(|_| ())?;
|
||||||
return None;
|
let spec = raw.strip_prefix("bytes=").ok_or(())?;
|
||||||
|
if spec.contains(',') || length == 0 {
|
||||||
|
return Err(());
|
||||||
}
|
}
|
||||||
let end = end
|
let (start, end) = spec.split_once('-').ok_or(())?;
|
||||||
.parse::<u64>()
|
if start.is_empty() {
|
||||||
.ok()
|
let suffix_len = end.parse::<u64>().map_err(|_| ())?;
|
||||||
.map(|end| end.min(length.saturating_sub(1)));
|
if suffix_len == 0 {
|
||||||
Some((start, end))
|
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) {
|
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 {
|
fn error_response(message_status: StatusCode, message: impl Into<String>) -> Response {
|
||||||
(message_status, Json(json!({ "error": message.into() }))).into_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}/")
|
format!("{without_manifest}/")
|
||||||
};
|
};
|
||||||
let lower = base.to_ascii_lowercase();
|
let lower = base.to_ascii_lowercase();
|
||||||
if lower.contains("localhost") || lower.contains("127.0.0.1") {
|
if (lower.contains("localhost") || lower.contains("127.0.0.1")) && lower.starts_with("https://")
|
||||||
if lower.starts_with("https://") {
|
{
|
||||||
base = format!("http://{}", &base[8..]);
|
base = format!("http://{}", &base[8..]);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
base
|
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/`
|
// 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.
|
// crate (see lib.rs). Not part of the supported public API otherwise.
|
||||||
pub fn parse_manifest(
|
pub fn parse_manifest(body: &str, transport_url: &str, unknown_name: &str) -> Option<String> {
|
||||||
body: &str,
|
|
||||||
transport_url: &str,
|
|
||||||
unknown_name: &str,
|
|
||||||
) -> Option<String> {
|
|
||||||
let json: Value = serde_json::from_str(body).ok()?;
|
let json: Value = serde_json::from_str(body).ok()?;
|
||||||
let behavior_hints = json.get("behaviorHints");
|
let behavior_hints = json.get("behaviorHints");
|
||||||
let logo = first_text(
|
let logo = first_text(
|
||||||
|
|
@ -978,7 +973,13 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn build_resource_url_appends_extra_path_segment_and_omits_blank_values() {
|
fn build_resource_url_appends_extra_path_segment_and_omits_blank_values() {
|
||||||
assert_eq!(
|
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"
|
"https://addon.example/stream/movie/tt123.json"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -1011,9 +1012,19 @@ mod tests {
|
||||||
"types": ["movie"],
|
"types": ["movie"],
|
||||||
"idPrefixes": ["tt"],
|
"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.
|
// 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!({
|
let catalog_manifest = json!({
|
||||||
"resources": [{ "name": "catalog", "types": ["movie"], "idPrefixes": ["tt"] }],
|
"resources": [{ "name": "catalog", "types": ["movie"], "idPrefixes": ["tt"] }],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::addon_protocol;
|
use crate::addon_protocol;
|
||||||
use serde_json::{json, Value};
|
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 {
|
match resource {
|
||||||
"stream" | "streams" => root.get("streams").cloned(),
|
"stream" | "streams" => root.get("streams").cloned(),
|
||||||
"catalog" | "metas" => root.get("metas").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 {
|
fn manifest_url_regex() -> &'static Regex {
|
||||||
static REGEX: OnceLock<Regex> = OnceLock::new();
|
static REGEX: OnceLock<Regex> = OnceLock::new();
|
||||||
REGEX.get_or_init(|| {
|
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
|
// See headless_engine::lock_engines — recovering from poison keeps this store
|
||||||
// usable after a single caught panic instead of going dark for every handle.
|
// usable after a single caught panic instead of going dark for every handle.
|
||||||
fn lock_store() -> std::sync::MutexGuard<'static, HashMap<u64, AppCoreState>> {
|
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 {
|
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]
|
#[uniffi::export]
|
||||||
pub fn create_headless_engine_json(initial_json: String) -> i64 {
|
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]
|
#[uniffi::export]
|
||||||
pub fn destroy_headless_engine_json(handle: i64) -> bool {
|
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]
|
#[uniffi::export]
|
||||||
|
|
@ -43,7 +48,8 @@ pub fn headless_engine_dispatch_json(handle: i64, action_json: String) -> String
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
guard(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]
|
#[uniffi::export]
|
||||||
pub fn core_capabilities_json(portable: bool) -> String {
|
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]
|
#[uniffi::export]
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ pub(crate) fn calendar_season_candidates_json(request_json: &str) -> Option<Stri
|
||||||
};
|
};
|
||||||
let mut result: Vec<i32> = focused
|
let mut result: Vec<i32> = focused
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(full.into_iter())
|
.chain(full)
|
||||||
.collect::<std::collections::BTreeSet<_>>()
|
.collect::<std::collections::BTreeSet<_>>()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect();
|
.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) {
|
let body_text = match (item.season_number, item.episode_number) {
|
||||||
(Some(s), Some(e)) => format!("{}:season:{}:episode:{}", item.title, s, e),
|
(Some(s), Some(e)) => format!("{}:season:{}:episode:{}", item.title, s, e),
|
||||||
_ => {
|
_ => [Some(item.title.as_str()), item.subtitle.as_deref()]
|
||||||
[Some(item.title.as_str()), item.subtitle.as_deref()]
|
.into_iter()
|
||||||
.into_iter()
|
.flatten()
|
||||||
.flatten()
|
.filter(|s| !s.is_empty())
|
||||||
.filter(|s| !s.is_empty())
|
.collect::<Vec<_>>()
|
||||||
.collect::<Vec<_>>()
|
.join(" - "),
|
||||||
.join(" - ")
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
items_out.push(json!({
|
items_out.push(json!({
|
||||||
"key": key,
|
"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: Value = serde_json::from_str(meta_json).ok()?;
|
||||||
let meta_id = meta.get("id").and_then(Value::as_str).unwrap_or("");
|
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_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));
|
.or_else(|| meta.get("background").and_then(Value::as_str));
|
||||||
let videos = meta.get("videos").and_then(Value::as_array)?;
|
let videos = meta.get("videos").and_then(Value::as_array)?;
|
||||||
let mut items: Vec<Value> = Vec::new();
|
let mut items: Vec<Value> = Vec::new();
|
||||||
for video in videos {
|
for video in videos {
|
||||||
let released = video.get("released").and_then(Value::as_str).unwrap_or("");
|
let released = video.get("released").and_then(Value::as_str).unwrap_or("");
|
||||||
let date_iso = match released.get(..10) { Some(d) => d, None => continue };
|
let date_iso = match released.get(..10) {
|
||||||
if !month_prefix.is_empty() && !date_iso.starts_with(month_prefix) { continue; }
|
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 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) {
|
let episode_code = match (season, episode) {
|
||||||
(Some(s), Some(e)) => Some(format!("S{s}:E{e}")),
|
(Some(s), Some(e)) => Some(format!("S{s}:E{e}")),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let video_name = video.get("name").or_else(|| video.get("title")).and_then(Value::as_str);
|
let video_name = video
|
||||||
let subtitle = [episode_code.as_deref(), video_name].into_iter().flatten().collect::<Vec<_>>().join(" ");
|
.get("name")
|
||||||
let poster = video.get("thumbnail").and_then(Value::as_str).or(meta_poster);
|
.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 video_id = video.get("id").and_then(Value::as_str).unwrap_or("");
|
||||||
let key = format!("{meta_id}:{video_id}:{date_iso}");
|
let key = format!("{meta_id}:{video_id}:{date_iso}");
|
||||||
items.push(json!({
|
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 {
|
pub(crate) fn calendar_item_matches_month_json(item_json: &str, month_prefix: &str) -> bool {
|
||||||
if month_prefix.is_empty() { return true; }
|
if month_prefix.is_empty() {
|
||||||
serde_json::from_str::<Value>(item_json).ok()
|
return true;
|
||||||
.and_then(|v| v.get("dateIso").and_then(Value::as_str).map(|d| d.starts_with(month_prefix)))
|
}
|
||||||
|
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)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,10 +372,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn season_candidates_covers_watched_next_and_last_season() {
|
fn season_candidates_covers_watched_next_and_last_season() {
|
||||||
let result: Value = serde_json::from_str(
|
let result: Value = serde_json::from_str(
|
||||||
&calendar_season_candidates_json(
|
&calendar_season_candidates_json(r#"{"seasonsCount":5,"lastVideoId":"tt1:2:3"}"#)
|
||||||
r#"{"seasonsCount":5,"lastVideoId":"tt1:2:3"}"#,
|
.unwrap(),
|
||||||
)
|
|
||||||
.unwrap(),
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let seasons: Vec<i64> = result
|
let seasons: Vec<i64> = result
|
||||||
|
|
@ -396,31 +419,35 @@ mod tests {
|
||||||
"notificationsEnabled": true,
|
"notificationsEnabled": true,
|
||||||
"alertNewEpisodes": true
|
"alertNewEpisodes": true
|
||||||
});
|
});
|
||||||
let result: Value =
|
let result: Value = serde_json::from_str(
|
||||||
serde_json::from_str(&calendar_notification_content_json(&request.to_string()).unwrap())
|
&calendar_notification_content_json(&request.to_string()).unwrap(),
|
||||||
.unwrap();
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(result["items"].as_array().unwrap().len(), 0);
|
assert_eq!(result["items"].as_array().unwrap().len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn next_unaired_episode_picks_earliest_future_date() {
|
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!([
|
let videos = json!([
|
||||||
{"id": "v1", "released": "2026-06-01T00:00:00Z"},
|
{"id": "v1", "released": "2026-06-01T00:00:00Z"},
|
||||||
{"id": "v2", "released": "2026-07-10T00:00:00Z"},
|
{"id": "v2", "released": "2026-07-10T00:00:00Z"},
|
||||||
{"id": "v3", "released": "2026-06-20T00:00:00Z"},
|
{"id": "v3", "released": "2026-06-20T00:00:00Z"},
|
||||||
{"id": "v4"}
|
{"id": "v4"}
|
||||||
]);
|
]);
|
||||||
let result: Value = serde_json::from_str(
|
let result: Value =
|
||||||
&next_unaired_episode_json(&videos.to_string(), now_ms).unwrap(),
|
serde_json::from_str(&next_unaired_episode_json(&videos.to_string(), now_ms).unwrap())
|
||||||
)
|
.unwrap();
|
||||||
.unwrap();
|
|
||||||
assert_eq!(result["id"], "v3");
|
assert_eq!(result["id"], "v3");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn next_unaired_episode_returns_none_when_nothing_upcoming() {
|
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!([
|
let videos = json!([
|
||||||
{"id": "v1", "released": "2026-06-01T00:00:00Z"},
|
{"id": "v1", "released": "2026-06-01T00:00:00Z"},
|
||||||
{"id": "v2"}
|
{"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 {
|
pub(crate) fn validate_stream_url(url: &str) -> bool {
|
||||||
let trimmed = url.trim();
|
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();
|
let scheme = trimmed[..scheme_end].to_ascii_lowercase();
|
||||||
if scheme != "http" && scheme != "https" {
|
if scheme != "http" && scheme != "https" {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -14,7 +16,10 @@ pub(crate) fn validate_stream_url(url: &str) -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn xml_escape(value: &str) -> String {
|
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> {
|
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 {
|
fn format_didl_metadata(title: &str, subtitle_url: Option<&str>) -> String {
|
||||||
let subtitle_res = subtitle_url
|
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();
|
.unwrap_or_default();
|
||||||
let didl = format!(
|
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/\">\
|
"<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)
|
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) {
|
if !validate_stream_url(media_url) {
|
||||||
return None;
|
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 {
|
pub(crate) fn format_hms(total_secs: f64) -> String {
|
||||||
let total = total_secs.max(0.0) as u64;
|
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 {
|
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 {
|
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 {
|
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") {
|
if path.ends_with(".m3u8") {
|
||||||
"application/x-mpegurl"
|
"application/x-mpegurl"
|
||||||
} else if path.ends_with(".mkv") {
|
} 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());
|
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();
|
let mut buf = Vec::new();
|
||||||
write_tag(&mut buf, 1, 0);
|
write_tag(&mut buf, 1, 0);
|
||||||
write_varint(&mut buf, 0);
|
write_varint(&mut buf, 0);
|
||||||
|
|
@ -206,14 +237,19 @@ pub(crate) fn decode_cast_message(buf: &[u8]) -> Option<DecodedCastMessage> {
|
||||||
_ => return None,
|
_ => return None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(DecodedCastMessage { namespace, payload_utf8 })
|
Some(DecodedCastMessage {
|
||||||
|
namespace,
|
||||||
|
payload_utf8,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn roku_url_encode(value: &str) -> String {
|
fn roku_url_encode(value: &str) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for byte in value.bytes() {
|
for byte in value.bytes() {
|
||||||
match byte {
|
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}")),
|
_ => 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")
|
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) {
|
if !validate_stream_url(media_url) {
|
||||||
return None;
|
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";
|
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 {
|
if let Some(sub) = subtitle_url {
|
||||||
url.push_str(&format!("&k={}", roku_url_encode(sub)));
|
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) {
|
if !validate_stream_url(media_url) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(format!("Content-Location: {media_url}\nStart-Position: 0\n"))
|
Some(format!(
|
||||||
|
"Content-Location: {media_url}\nStart-Position: 0\n"
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -275,7 +320,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn didl_title_with_markup_does_not_break_out_of_the_item_tag() {
|
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>"));
|
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) {
|
if let Some(imdb) = imdb_id(id) {
|
||||||
return imdb;
|
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(
|
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 group = parse_string_list(group_keys_json);
|
||||||
let mut output = Vec::<String>::new();
|
let mut output = Vec::<String>::new();
|
||||||
for item in current {
|
for item in current {
|
||||||
if enabled || !group.contains(&item) {
|
if (enabled || !group.contains(&item)) && !output.contains(&item) {
|
||||||
if !output.contains(&item) {
|
output.push(item);
|
||||||
output.push(item);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if enabled {
|
if enabled {
|
||||||
|
|
@ -1242,7 +1242,8 @@ pub(crate) fn parse_video_id_json(id: &str) -> String {
|
||||||
} else {
|
} else {
|
||||||
map.insert("isEpisode".into(), false.into());
|
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> {
|
pub(crate) fn build_trakt_ids_json(video_id: &str) -> Option<String> {
|
||||||
|
|
@ -1384,7 +1385,11 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn contains_spaced_episode_matches_word_form_and_skips_wrong_season_occurrence() {
|
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
|
// First "Season 2" occurrence doesn't match the target season (1), so the
|
||||||
// scan must continue past it to the second "Season ... Episode ..." pair.
|
// scan must continue past it to the second "Season ... Episode ..." pair.
|
||||||
assert!(contains_spaced_episode(
|
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, 1));
|
||||||
assert!(contains_spaced_episode("Season 1 Episode 10", 1, 10));
|
assert!(contains_spaced_episode("Season 1 Episode 10", 1, 10));
|
||||||
// A season number that matches but with no "Episode" anywhere after it.
|
// 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]
|
#[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;
|
pub struct FluxaCore;
|
||||||
|
|
||||||
|
|
@ -9,90 +11,155 @@ fn guard<T>(default: T, f: impl FnOnce() -> T) -> T {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FluxaCore {
|
impl FluxaCore {
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
pub fn create_headless_engine(initial_json: &str) -> u64 {
|
pub fn create_headless_engine(initial_json: &str) -> u64 {
|
||||||
guard(0, || headless_engine::create_headless_engine(initial_json))
|
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> {
|
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> {
|
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> {
|
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> {
|
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> {
|
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> {
|
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> {
|
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 {
|
pub fn validate_stream_url(url: &str) -> bool {
|
||||||
guard(false, || cast_protocol::validate_stream_url(url))
|
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> {
|
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 {
|
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> {
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
guard(None, || cast_protocol::dlna_set_av_transport_args(media_url, title, subtitle_url))
|
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 {
|
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 {
|
pub fn dlna_set_volume_args(level: f64) -> String {
|
||||||
guard(String::new(), || cast_protocol::dlna_set_volume_args(level))
|
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 {
|
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 {
|
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> {
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
guard(Vec::new(), || cast_protocol::encode_cast_message(source_id, destination_id, namespace, payload_utf8))
|
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)> {
|
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> {
|
pub fn roku_device_name(xml: &str) -> Option<String> {
|
||||||
guard(None, || cast_protocol::roku_device_name(xml))
|
guard(None, || cast_protocol::roku_device_name(xml))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn roku_launch_url(host: &str, media_url: &str, subtitle_url: Option<&str>) -> Option<String> {
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
guard(None, || cast_protocol::roku_launch_url(host, media_url, subtitle_url))
|
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 {
|
pub fn airplay_volume_db(level: f64) -> f64 {
|
||||||
guard(-30.0, || cast_protocol::airplay_volume_db(level))
|
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> {
|
pub fn airplay_play_body(media_url: &str) -> Option<String> {
|
||||||
guard(None, || cast_protocol::airplay_play_body(media_url))
|
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 ::dolby_vision::rpu::dovi_rpu::DoviRpu;
|
||||||
|
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -84,7 +84,9 @@ pub fn dolby_vision_convert_rpu_json(input: &str) -> Option<String> {
|
||||||
rpu_base64: Some(BASE64.encode(&out)),
|
rpu_base64: Some(BASE64.encode(&out)),
|
||||||
error: None,
|
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()),
|
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
|
// to panic (byte-range slicing assumed 1 char == 1 byte); it must now
|
||||||
// surface as an error.
|
// surface as an error.
|
||||||
assert!(hex_decode("aébb").is_err());
|
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> {
|
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 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()
|
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 source = movie.or(show)?;
|
||||||
let id = trakt_id_from_source(source)?;
|
let id = trakt_id_from_source(source)?;
|
||||||
let progress = item.get("progress").and_then(Value::as_f64).unwrap_or(0.0);
|
let progress = item.get("progress").and_then(Value::as_f64).unwrap_or(0.0);
|
||||||
if progress < 1.0 { return None; }
|
if progress < 1.0 {
|
||||||
let title = source.get("title").or_else(|| source.get("name"))
|
return None;
|
||||||
.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 title = source
|
||||||
let ep_runtime = episode.and_then(|e| e.get("runtime")).and_then(Value::as_f64);
|
.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
|
let runtime_min = ep_runtime
|
||||||
.or_else(|| source.get("runtime").and_then(Value::as_f64))
|
.or_else(|| source.get("runtime").and_then(Value::as_f64))
|
||||||
.unwrap_or(if movie.is_some() { 100.0 } else { 45.0 });
|
.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 {
|
} else {
|
||||||
id.clone()
|
id.clone()
|
||||||
};
|
};
|
||||||
let episode_season = episode.and_then(|e| e.get("season")).and_then(Value::as_i64);
|
let episode_season = episode
|
||||||
let episode_number = episode.and_then(|e| e.get("number")).and_then(Value::as_i64);
|
.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("");
|
let saved_at = item.get("paused_at").and_then(Value::as_str).unwrap_or("");
|
||||||
Some(json!({
|
Some(json!({
|
||||||
"id": id,
|
"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 shows: Vec<Value> = serde_json::from_str(shows_json).unwrap_or_default();
|
||||||
let mut ids: serde_json::Map<String, Value> = serde_json::Map::new();
|
let mut ids: serde_json::Map<String, Value> = serde_json::Map::new();
|
||||||
for entry in &movies {
|
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(|m| m.get("ids"))
|
||||||
.and_then(|ids| ids.get("imdb"))
|
.and_then(|ids| ids.get("imdb"))
|
||||||
.and_then(Value::as_str)
|
.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 {
|
for entry in &shows {
|
||||||
let imdb = match entry.get("show")
|
let imdb = match entry
|
||||||
|
.get("show")
|
||||||
.and_then(|s| s.get("ids"))
|
.and_then(|s| s.get("ids"))
|
||||||
.and_then(|ids| ids.get("imdb"))
|
.and_then(|ids| ids.get("imdb"))
|
||||||
.and_then(Value::as_str)
|
.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,
|
Some(s) => s,
|
||||||
None => continue,
|
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 {
|
for season in &seasons {
|
||||||
let s_num = season.get("number").and_then(Value::as_i64).unwrap_or(0);
|
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 {
|
for ep in &episodes {
|
||||||
let e_num = ep.get("number").and_then(Value::as_i64).unwrap_or(0);
|
let e_num = ep.get("number").and_then(Value::as_i64).unwrap_or(0);
|
||||||
if s_num > 0 && e_num > 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 {
|
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 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 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))
|
.filter_map(|i| i.get("id").and_then(Value::as_str).map(str::to_string))
|
||||||
.collect();
|
.collect();
|
||||||
for item in external {
|
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 {
|
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 mut local: serde_json::Map<String, Value> =
|
||||||
let external: serde_json::Map<String, Value> = serde_json::from_str(external_json).unwrap_or_default();
|
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 {
|
for (id, val) in external {
|
||||||
if val.as_bool() == Some(true) && !local.contains_key(&id) {
|
if val.as_bool() == Some(true) && !local.contains_key(&id) {
|
||||||
local.insert(id, Value::Bool(true));
|
local.insert(id, Value::Bool(true));
|
||||||
|
|
@ -383,29 +413,33 @@ pub(crate) fn merge_continue_watching_lists_json(
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let local: Vec<Value> = serde_json::from_str(local_json).unwrap_or_default();
|
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 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 {
|
fn item_id(item: &Value) -> String {
|
||||||
item.get("id").or_else(|| item.get("_id"))
|
item.get("id")
|
||||||
.and_then(Value::as_str).unwrap_or("").to_string()
|
.or_else(|| item.get("_id"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn saved_at_ms(item: &Value) -> i64 {
|
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())
|
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||||
.map(|dt: chrono::DateTime<chrono::FixedOffset>| dt.timestamp_millis())
|
.map(|dt: chrono::DateTime<chrono::FixedOffset>| dt.timestamp_millis())
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
let local_by_id: std::collections::HashMap<String, &Value> = local.iter()
|
let local_by_id: std::collections::HashMap<String, &Value> =
|
||||||
.map(|item| (item_id(item), item))
|
local.iter().map(|item| (item_id(item), item)).collect();
|
||||||
.collect();
|
let external_by_id: std::collections::HashMap<String, &Value> =
|
||||||
let external_by_id: std::collections::HashMap<String, &Value> = external.iter()
|
external.iter().map(|item| (item_id(item), item)).collect();
|
||||||
.map(|item| (item_id(item), item))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
fn local_saved_at_from_progress(progress: &serde_json::Map<String, Value>, id: &str) -> i64 {
|
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(|entry| entry.get("savedAt"))
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
.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 {
|
for entry in &shows {
|
||||||
let show = entry.get("show")?;
|
let show = entry.get("show")?;
|
||||||
let ids = show.get("ids")?;
|
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 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"));
|
.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!({
|
items.push(json!({
|
||||||
"id": imdb, "type": "series", "name": title,
|
"id": imdb, "type": "series", "name": title,
|
||||||
"poster": poster, "continueWatchingBadge": "upNext",
|
"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 {
|
for entry in &movies {
|
||||||
let movie = entry.get("movie")?;
|
let movie = entry.get("movie")?;
|
||||||
let ids = movie.get("ids")?;
|
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 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"));
|
.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!({
|
items.push(json!({
|
||||||
"id": imdb, "type": "movie", "name": title,
|
"id": imdb, "type": "movie", "name": title,
|
||||||
"poster": poster, "savedAt": saved_at, "reason": "simkl"
|
"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 {
|
for entry in &shows {
|
||||||
let show = entry.get("show")?;
|
let show = entry.get("show")?;
|
||||||
let ids = show.get("ids")?;
|
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 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"));
|
.map(|p| format!("https://simkl.in/posters/{p}_m.jpg"));
|
||||||
items.push(json!({ "id": imdb, "name": title, "type": "series", "source": "simkl", "poster": poster }));
|
items.push(json!({ "id": imdb, "name": title, "type": "series", "source": "simkl", "poster": poster }));
|
||||||
}
|
}
|
||||||
for entry in &movies {
|
for entry in &movies {
|
||||||
let movie = entry.get("movie")?;
|
let movie = entry.get("movie")?;
|
||||||
let ids = movie.get("ids")?;
|
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 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"));
|
.map(|p| format!("https://simkl.in/posters/{p}_m.jpg"));
|
||||||
items.push(json!({ "id": imdb, "name": title, "type": "movie", "source": "simkl", "poster": poster }));
|
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 movies: Vec<Value> = serde_json::from_str(movies_json).unwrap_or_default();
|
||||||
let mut ids: serde_json::Map<String, Value> = serde_json::Map::new();
|
let mut ids: serde_json::Map<String, Value> = serde_json::Map::new();
|
||||||
for entry in &shows {
|
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(|s| s.get("ids"))
|
||||||
.and_then(|i| i.get("imdb"))
|
.and_then(|i| i.get("imdb"))
|
||||||
.and_then(Value::as_str)
|
.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 {
|
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(|m| m.get("ids"))
|
||||||
.and_then(|i| i.get("imdb"))
|
.and_then(|i| i.get("imdb"))
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
|
|
@ -536,7 +598,10 @@ pub(crate) fn replace_external_continue_watching_json(
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|item| {
|
.filter(|item| {
|
||||||
let id = item.get("id").and_then(Value::as_str).unwrap_or("").trim();
|
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);
|
let duration = item.get("duration").and_then(Value::as_f64).unwrap_or(0.0);
|
||||||
!id.is_empty() && offset > 0.0 && duration > 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 combined = base.into_iter().chain(incoming_filtered);
|
||||||
let mut by_id: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
|
let mut by_id: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
|
||||||
for item in combined {
|
for item in combined {
|
||||||
let id = item.get("id").and_then(Value::as_str).unwrap_or("").to_string();
|
let id = item
|
||||||
if id.is_empty() { continue; }
|
.get("id")
|
||||||
let item_time = item.get("savedAt").and_then(Value::as_str).unwrap_or("").to_string();
|
.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) {
|
match by_id.get(&id) {
|
||||||
Some(prev) => {
|
Some(prev) => {
|
||||||
let prev_time = prev.get("savedAt").and_then(Value::as_str).unwrap_or("");
|
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> {
|
pub(crate) fn trakt_playback_items_dedup_json(items_json: &str) -> Option<String> {
|
||||||
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
|
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("")
|
item.get("savedAt").and_then(Value::as_str).unwrap_or("")
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut best: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
|
let mut best: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
|
||||||
for item in items {
|
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() {
|
if id.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let cur = saved_at_str(&item).to_string();
|
let cur = saved_at_str(&item).to_string();
|
||||||
match best.get(&id) {
|
match best.get(&id) {
|
||||||
None => { best.insert(id, item); }
|
None => {
|
||||||
Some(existing) if cur.as_str() > saved_at_str(existing) => { best.insert(id, item); }
|
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> {
|
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 video_ids: Vec<String> = serde_json::from_str(video_ids_json).ok()?;
|
||||||
let mut movie_ids: Vec<Value> = Vec::new();
|
let mut movie_ids: Vec<Value> = Vec::new();
|
||||||
let mut shows: std::collections::HashMap<String, (Value, std::collections::BTreeMap<i64, Vec<i64>>)> =
|
let mut shows: std::collections::HashMap<
|
||||||
std::collections::HashMap::new();
|
String,
|
||||||
|
(Value, std::collections::BTreeMap<i64, Vec<i64>>),
|
||||||
|
> = std::collections::HashMap::new();
|
||||||
|
|
||||||
for vid in &video_ids {
|
for vid in &video_ids {
|
||||||
let parsed_json = parse_video_id_json(vid);
|
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,
|
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 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 episode = parsed.get("episode").and_then(Value::as_i64).unwrap_or(1);
|
||||||
let show_id = parsed
|
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() {
|
if show_id.is_empty() {
|
||||||
continue;
|
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);
|
entry.1.entry(season).or_default().push(episode);
|
||||||
} else {
|
} else {
|
||||||
movie_ids.push(json!({ "ids": ids }));
|
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> {
|
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 episodes: Vec<Value> = serde_json::from_str(episodes_json).ok()?;
|
||||||
let target: Value = serde_json::from_str(target_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
|
let title = target
|
||||||
.get("title")
|
.get("title")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
|
|
@ -762,7 +860,13 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn trakt_mark_watched_body_groups_episodes_by_show_and_dedupes() {
|
fn trakt_mark_watched_body_groups_episodes_by_show_and_dedupes() {
|
||||||
let body = trakt_mark_watched_body_json(
|
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())
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
.expect("body");
|
.expect("body");
|
||||||
|
|
@ -786,6 +890,9 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn trakt_mark_watched_body_is_none_for_unrecognized_ids() {
|
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 {
|
fn fail(kind: ErrorKind, message: impl Into<String>) -> CallError {
|
||||||
CallError { kind, message: message.into() }
|
CallError {
|
||||||
|
kind,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Outcome = Result<Value, CallError>;
|
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
|
// 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
|
// process down with it — catch it here and hand back the same error
|
||||||
// envelope shape callers already handle for any other failure.
|
// 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 {
|
match outcome {
|
||||||
Ok(Ok(value)) => json!({ "ok": true, "value": value }).to_string(),
|
Ok(Ok(value)) => json!({ "ok": true, "value": value }).to_string(),
|
||||||
Ok(Err(e)) => json!({
|
Ok(Err(e)) => json!({
|
||||||
|
|
@ -85,16 +89,24 @@ const ROUTERS: &[fn(&str, &str) -> Outcome] = &[
|
||||||
fn route(method: &str, args_json: &str) -> Outcome {
|
fn route(method: &str, args_json: &str) -> Outcome {
|
||||||
for router in ROUTERS {
|
for router in ROUTERS {
|
||||||
match router(method, args_json) {
|
match router(method, args_json) {
|
||||||
Err(CallError { kind: ErrorKind::UnknownMethod, .. }) => continue,
|
Err(CallError {
|
||||||
|
kind: ErrorKind::UnknownMethod,
|
||||||
|
..
|
||||||
|
}) => continue,
|
||||||
result => return result,
|
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 {
|
fn route_engine_lifecycle(method: &str, args_json: &str) -> Outcome {
|
||||||
match method {
|
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(
|
"engine.snapshot" => result_json(
|
||||||
headless_engine::headless_engine_snapshot_json(handle(args_json)?),
|
headless_engine::headless_engine_snapshot_json(handle(args_json)?),
|
||||||
method,
|
method,
|
||||||
|
|
@ -119,7 +131,9 @@ fn route_engine_lifecycle(method: &str, args_json: &str) -> Outcome {
|
||||||
method,
|
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 state (parallel to headless engine, used by Android)
|
||||||
"app.create" => Ok(json!(app_state::create_app_core_state(args_json) as i64)),
|
"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)?))),
|
"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 {
|
fn route_addon_protocol(method: &str, args_json: &str) -> Outcome {
|
||||||
match method {
|
match method {
|
||||||
"identity" => Ok(Value::String(addon_protocol::identity(&arg_str(args_json, "url")?))),
|
"identity" => Ok(Value::String(addon_protocol::identity(&arg_str(
|
||||||
"normalizeManifestUrl" => Ok(Value::String(addon_protocol::normalize_manifest_url(&arg_str(args_json, "url")?))),
|
args_json, "url",
|
||||||
"manifestFetchPlan" => opt_json(addon_protocol::manifest_fetch_plan_json(&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" => {
|
"parseManifest" => {
|
||||||
let args = object(args_json)?;
|
let args = object(args_json)?;
|
||||||
opt_json(addon_protocol::parse_manifest(
|
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
|
// 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" => {
|
"mergeLiveManifest" => {
|
||||||
let args = object(args_json)?;
|
let args = object(args_json)?;
|
||||||
let live = args.get("live").and_then(Value::as_str).map(str::to_string);
|
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(
|
opt_json(addon_protocol::merge_live_manifest_json(
|
||||||
field_str(&args, "descriptor")?,
|
field_str(&args, "descriptor")?,
|
||||||
live.as_deref(),
|
live.as_deref(),
|
||||||
|
|
@ -167,7 +195,10 @@ fn route_addon_protocol(method: &str, args_json: &str) -> Outcome {
|
||||||
}
|
}
|
||||||
"buildResourceUrl" => {
|
"buildResourceUrl" => {
|
||||||
let args = object(args_json)?;
|
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(
|
Ok(Value::String(addon_protocol::build_resource_url(
|
||||||
field_str(&args, "transportUrl")?,
|
field_str(&args, "transportUrl")?,
|
||||||
field_str(&args, "resource")?,
|
field_str(&args, "resource")?,
|
||||||
|
|
@ -178,7 +209,10 @@ fn route_addon_protocol(method: &str, args_json: &str) -> Outcome {
|
||||||
}
|
}
|
||||||
"supportsResource" => {
|
"supportsResource" => {
|
||||||
let args = object(args_json)?;
|
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);
|
let id = args.get("id").and_then(Value::as_str).map(str::to_string);
|
||||||
Ok(json!(addon_protocol::supports_resource(
|
Ok(json!(addon_protocol::supports_resource(
|
||||||
field_str(&args, "manifest")?,
|
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" => {
|
"parseAddonResourceResult" => {
|
||||||
let args = object(args_json)?;
|
let args = object(args_json)?;
|
||||||
let body = args.get("body").and_then(Value::as_str).map(str::to_string);
|
let body = args.get("body").and_then(Value::as_str).map(str::to_string);
|
||||||
let status_code = field(&args, "statusCode")?.as_i64()
|
let status_code = field(&args, "statusCode")?
|
||||||
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "statusCode must be a number"))? as i32;
|
.as_i64()
|
||||||
|
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "statusCode must be a number"))?
|
||||||
|
as i32;
|
||||||
into_json(addon_resource::parse_addon_resource_result_json(
|
into_json(addon_resource::parse_addon_resource_result_json(
|
||||||
field_str(&args, "resource")?,
|
field_str(&args, "resource")?,
|
||||||
field_str(&args, "url")?,
|
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 {
|
fn route_resource_plan(method: &str, args_json: &str) -> Outcome {
|
||||||
match method {
|
match method {
|
||||||
// Repository / resource flow — args_json IS the request object
|
// 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)),
|
"resourceFetchPlan" => opt_json(platform_plan::resource_fetch_plan_json(args_json)),
|
||||||
"resourceParsePlan" => opt_json(platform_plan::resource_parse_plan_json(args_json)),
|
"resourceParsePlan" => opt_json(platform_plan::resource_parse_plan_json(args_json)),
|
||||||
|
|
||||||
// Platform plan — args_json IS the request object
|
// Platform plan — args_json IS the request object
|
||||||
"playbackPreparePlan" => opt_json(platform_plan::playback_prepare_plan_json(args_json)),
|
"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()),
|
"preferencesSchema" => into_json(platform_plan::preferences_schema_json()),
|
||||||
"applyPreferenceUpdate" => opt_json(platform_plan::apply_preference_update_json(args_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)),
|
"detailEpisodePlan" => opt_json(platform_plan::detail_episode_plan_json(args_json)),
|
||||||
"resourceKindToResource" => {
|
"resourceKindToResource" => {
|
||||||
let args = object(args_json)?;
|
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)),
|
"torrentRuntimeInfo" => opt_json(stream_policy::torrent_runtime_info_json(args_json)),
|
||||||
"findPreferredSubtitleIndex" => {
|
"findPreferredSubtitleIndex" => {
|
||||||
let args = object(args_json)?;
|
let args = object(args_json)?;
|
||||||
let last = args.get("lastSubtitleLanguage").and_then(Value::as_str).map(str::to_string);
|
let last = args
|
||||||
let preferred = args.get("preferredSubtitleLanguage").and_then(Value::as_str).map(str::to_string);
|
.get("lastSubtitleLanguage")
|
||||||
let secondary = args.get("secondarySubtitleLanguage").and_then(Value::as_str).map(str::to_string);
|
.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(
|
Ok(json!(stream_policy::find_preferred_subtitle_index(
|
||||||
field_str(&args, "tracks")?,
|
field_str(&args, "tracks")?,
|
||||||
last.as_deref(),
|
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 {
|
match method {
|
||||||
// args_json IS the request object for single-arg methods
|
// args_json IS the request object for single-arg methods
|
||||||
"searchResultGrouping" => opt_json(search_plan::search_result_grouping_json(args_json)),
|
"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" => {
|
"discoverCatalogOptions" => {
|
||||||
let args = object(args_json)?;
|
let args = object(args_json)?;
|
||||||
opt_json(search_plan::discover_catalog_options_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)),
|
"discoverSortPlan" => opt_json(search_plan::discover_sort_plan_json(args_json)),
|
||||||
"librarySortPlan" => opt_json(search_plan::library_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)),
|
"detailSeasonLoadPlan" => opt_json(search_plan::detail_season_load_plan_json(args_json)),
|
||||||
"resolveTransportUrl" => {
|
"resolveTransportUrl" => {
|
||||||
let args = object(args_json)?;
|
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 {
|
fn route_player_policy(method: &str, args_json: &str) -> Outcome {
|
||||||
match method {
|
match method {
|
||||||
// args_json IS the request object for single-arg methods
|
// 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)),
|
"playerBufferTargets" => opt_json(player_policy::player_buffer_targets_json(args_json)),
|
||||||
"playerRetryPolicy" => opt_json(player_policy::player_retry_policy_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" => {
|
"canPrefetchNextEpisode" => {
|
||||||
let args = object(args_json)?;
|
let args = object(args_json)?;
|
||||||
Ok(json!(player_policy::can_prefetch_next_episode_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 {
|
match method {
|
||||||
// args_json IS the request object
|
// args_json IS the request object
|
||||||
"watchlistTogglePlan" => opt_json(watchlist_plan::watchlist_toggle_plan_json(args_json)),
|
"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" => {
|
"libraryApplyMarkWatched" => {
|
||||||
let args = object(args_json)?;
|
let args = object(args_json)?;
|
||||||
opt_json(watchlist_plan::library_apply_mark_watched_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)),
|
"importCollections" => opt_json(watchlist_plan::import_collections_json(args_json)),
|
||||||
"exportCollections" => opt_json(watchlist_plan::export_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
|
// args_json IS the request object
|
||||||
"offlineDownloadPlan" => opt_json(offline_download::offline_download_plan_json(args_json)),
|
"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 {
|
fn route_content_identity(method: &str, args_json: &str) -> Outcome {
|
||||||
match method {
|
match method {
|
||||||
"parseVideoId" => into_json(content_identity::parse_video_id_json(&arg_str(args_json, "id")?)),
|
"parseVideoId" => into_json(content_identity::parse_video_id_json(&arg_str(
|
||||||
"buildTraktIds" => opt_json(content_identity::build_trakt_ids_json(&arg_str(args_json, "id")?)),
|
args_json, "id",
|
||||||
"playbackIntroLookupContentId" => Ok(Value::String(content_identity::playback_intro_lookup_content_id(&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" => {
|
"effectiveMetadataFeedSelection" => {
|
||||||
let args = object(args_json)?;
|
let args = object(args_json)?;
|
||||||
opt_json(content_identity::effective_metadata_feed_selection_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" => {
|
"toggleMetadataFeedLimited" => {
|
||||||
let args = object(args_json)?;
|
let args = object(args_json)?;
|
||||||
let max_enabled = field(&args, "maxEnabled")?.as_i64()
|
let max_enabled = field(&args, "maxEnabled")?
|
||||||
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "maxEnabled must be a number"))? as i32;
|
.as_i64()
|
||||||
|
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "maxEnabled must be a number"))?
|
||||||
|
as i32;
|
||||||
opt_json(content_identity::toggle_metadata_feed_limited_json(
|
opt_json(content_identity::toggle_metadata_feed_limited_json(
|
||||||
field_str(&args, "selectedKeys")?,
|
field_str(&args, "selectedKeys")?,
|
||||||
field_str(&args, "availableKeys")?,
|
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" => {
|
"nextUnairedEpisode" => {
|
||||||
let args = object(args_json)?;
|
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"))?;
|
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "nowMs must be a number"))?;
|
||||||
opt_json(calendar_plan::next_unaired_episode_json(
|
opt_json(calendar_plan::next_unaired_episode_json(
|
||||||
field_str(&args, "videosJson")?,
|
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 {
|
fn route_external_sync_trakt(method: &str, args_json: &str) -> Outcome {
|
||||||
match method {
|
match method {
|
||||||
// args_json IS the items array for single-array-arg methods
|
// 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" => {
|
"traktWatchlistToItems" => {
|
||||||
let args = object(args_json)?;
|
let args = object(args_json)?;
|
||||||
opt_json(external_sync::trakt_watchlist_to_items_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 args = object(args_json)?;
|
||||||
let season = args.get("season").and_then(Value::as_i64);
|
let season = args.get("season").and_then(Value::as_i64);
|
||||||
let ep_number = args.get("epNumber").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"))?;
|
.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"))?;
|
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "durationSec must be a number"))?;
|
||||||
let ids_json = content_identity::build_trakt_ids_json(field_str(&args, "videoId")?)
|
let ids_json = content_identity::build_trakt_ids_json(field_str(&args, "videoId")?)
|
||||||
.ok_or_else(|| fail(ErrorKind::NotFound, "could not build trakt ids"))?;
|
.ok_or_else(|| fail(ErrorKind::NotFound, "could not build trakt ids"))?;
|
||||||
opt_json(player_scrobble::trakt_scrobble_plan_json(
|
opt_json(player_scrobble::trakt_scrobble_plan_json(
|
||||||
&ids_json,
|
&ids_json,
|
||||||
field(&args, "isEpisode")?.as_bool()
|
field(&args, "isEpisode")?
|
||||||
|
.as_bool()
|
||||||
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "isEpisode must be bool"))?,
|
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "isEpisode must be bool"))?,
|
||||||
season,
|
season,
|
||||||
ep_number,
|
ep_number,
|
||||||
|
|
@ -519,10 +624,15 @@ fn route_external_sync_trakt(method: &str, args_json: &str) -> Outcome {
|
||||||
field_str(&args, "itemsJson")?,
|
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)),
|
"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" => {
|
"simklScrobbleBody" => {
|
||||||
let args = object(args_json)?;
|
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"))?;
|
.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"))?;
|
.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"))?;
|
.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"))?;
|
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "durationSec must be a number"))?;
|
||||||
opt_json(player_scrobble::simkl_scrobble_body_json(
|
opt_json(player_scrobble::simkl_scrobble_body_json(
|
||||||
field_str(&args, "idsJson")?,
|
field_str(&args, "idsJson")?,
|
||||||
field(&args, "isEpisode")?.as_bool()
|
field(&args, "isEpisode")?
|
||||||
|
.as_bool()
|
||||||
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "isEpisode must be bool"))?,
|
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "isEpisode must be bool"))?,
|
||||||
season,
|
season,
|
||||||
ep_number,
|
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 {
|
fn route_library_state(method: &str, args_json: &str) -> Outcome {
|
||||||
match method {
|
match method {
|
||||||
// args_json IS the items/item/doc JSON for single-arg methods
|
// args_json IS the items/item/doc JSON for single-arg methods
|
||||||
"libraryContinueWatchingItems" => opt_json(library_state::library_continue_watching_items_json(args_json)),
|
"libraryContinueWatchingItems" => opt_json(
|
||||||
"normalizeLibraryDocument" => into_json(library_state::normalize_library_document_json(args_json)),
|
library_state::library_continue_watching_items_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)),
|
"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" => {
|
"rememberLastWatchedEpisodes" => {
|
||||||
let args = object(args_json)?;
|
let args = object(args_json)?;
|
||||||
into_json(library_state::remember_last_watched_episodes_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" => {
|
"computeContinueWatchingBadges" => {
|
||||||
let args = object(args_json)?;
|
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"))?;
|
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "nowMs must be a number"))?;
|
||||||
opt_json(library_state::compute_continue_watching_badges_json(
|
opt_json(library_state::compute_continue_watching_badges_json(
|
||||||
field_str(&args, "candidatesJson")?,
|
field_str(&args, "candidatesJson")?,
|
||||||
|
|
@ -610,10 +737,18 @@ fn route_library_state(method: &str, args_json: &str) -> Outcome {
|
||||||
let args = object(args_json)?;
|
let args = object(args_json)?;
|
||||||
opt_json(library_state::resolve_next_episode_json(
|
opt_json(library_state::resolve_next_episode_json(
|
||||||
&field(&args, "videos")?.to_string(),
|
&field(&args, "videos")?.to_string(),
|
||||||
field(&args, "currentSeason")?.as_i64().ok_or_else(|| fail(ErrorKind::InvalidArgs, "currentSeason must be a number"))?,
|
field(&args, "currentSeason")?.as_i64().ok_or_else(|| {
|
||||||
field(&args, "currentEpisode")?.as_i64().ok_or_else(|| fail(ErrorKind::InvalidArgs, "currentEpisode must be a number"))?,
|
fail(ErrorKind::InvalidArgs, "currentSeason 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, "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" => {
|
"formatEpisodeLine" => {
|
||||||
|
|
@ -630,7 +765,9 @@ fn route_library_state(method: &str, args_json: &str) -> Outcome {
|
||||||
Ok(json!(library_state::select_continue_watching_artwork_json(
|
Ok(json!(library_state::select_continue_watching_artwork_json(
|
||||||
&field(&args, "item")?.to_string(),
|
&field(&args, "item")?.to_string(),
|
||||||
field_str(&args, "artworkPreference")?,
|
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" => {
|
"continueWatchingCardFields" => {
|
||||||
|
|
@ -638,7 +775,9 @@ fn route_library_state(method: &str, args_json: &str) -> Outcome {
|
||||||
opt_json(library_state::continue_watching_card_fields_json(
|
opt_json(library_state::continue_watching_card_fields_json(
|
||||||
&field(&args, "items")?.to_string(),
|
&field(&args, "items")?.to_string(),
|
||||||
field_str(&args, "artworkPreference")?,
|
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" => {
|
"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 {
|
fn route_tmdb(method: &str, args_json: &str) -> Outcome {
|
||||||
match method {
|
match method {
|
||||||
"tmdbContentType" => Ok(Value::String(tmdb_plan::tmdb_content_type(&arg_str(args_json, "contentType")?).to_string())),
|
"tmdbContentType" => Ok(Value::String(
|
||||||
"tmdbLanguage" => Ok(Value::String(tmdb_plan::tmdb_language(&arg_str(args_json, "language")?))),
|
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" => {
|
"tmdbImageUrl" => {
|
||||||
let args = object(args_json)?;
|
let args = object(args_json)?;
|
||||||
Ok(json!(tmdb_plan::tmdb_image_url(
|
Ok(json!(tmdb_plan::tmdb_image_url(
|
||||||
|
|
@ -682,13 +828,19 @@ fn route_tmdb(method: &str, args_json: &str) -> Outcome {
|
||||||
field_str(&args, "language")?,
|
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" => {
|
"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]))
|
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)),
|
"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 {
|
fn route_core_contract(method: &str, args_json: &str) -> Outcome {
|
||||||
match method {
|
match method {
|
||||||
"coreCapabilities" => into_json(core_contract::core_capabilities_json(
|
"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 {
|
fn opt_json(value: Option<String>) -> Outcome {
|
||||||
Ok(match value {
|
Ok(match value {
|
||||||
Some(s) => serde_json::from_str(&s)
|
Some(s) => serde_json::from_str(&s).map_err(|e| {
|
||||||
.map_err(|e| fail(ErrorKind::Internal, format!("core produced invalid JSON: {e}")))?,
|
fail(
|
||||||
|
ErrorKind::Internal,
|
||||||
|
format!("core produced invalid JSON: {e}"),
|
||||||
|
)
|
||||||
|
})?,
|
||||||
None => Value::Null,
|
None => Value::Null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn object(args_json: &str) -> Result<Value, CallError> {
|
fn object(args_json: &str) -> Result<Value, CallError> {
|
||||||
let value: Value = serde_json::from_str(args_json)
|
let value: Value = serde_json::from_str(args_json).map_err(|e| {
|
||||||
.map_err(|e| fail(ErrorKind::InvalidArgs, format!("args is not valid JSON: {e}")))?;
|
fail(
|
||||||
|
ErrorKind::InvalidArgs,
|
||||||
|
format!("args is not valid JSON: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
if value.is_object() {
|
if value.is_object() {
|
||||||
Ok(value)
|
Ok(value)
|
||||||
} else {
|
} 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> {
|
fn field_str<'a>(args: &'a Value, name: &str) -> Result<&'a str, CallError> {
|
||||||
field(args, name)?
|
field(args, name)?.as_str().ok_or_else(|| {
|
||||||
.as_str()
|
fail(
|
||||||
.ok_or_else(|| fail(ErrorKind::InvalidArgs, format!("field `{name}` must be a string")))
|
ErrorKind::InvalidArgs,
|
||||||
|
format!("field `{name}` must be a string"),
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn field_u64(args: &Value, name: &str) -> Result<u64, CallError> {
|
fn field_u64(args: &Value, name: &str) -> Result<u64, CallError> {
|
||||||
field(args, name)?.as_u64().ok_or_else(|| {
|
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> {
|
fn handle(args_json: &str) -> Result<u64, CallError> {
|
||||||
let value: Value = serde_json::from_str(args_json)
|
let value: Value = serde_json::from_str(args_json).map_err(|e| {
|
||||||
.map_err(|e| fail(ErrorKind::InvalidArgs, format!("args is not valid JSON: {e}")))?;
|
fail(
|
||||||
|
ErrorKind::InvalidArgs,
|
||||||
|
format!("args is not valid JSON: {e}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
value
|
value
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.or_else(|| value.get("handle").and_then(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 {
|
fn result_json(value: Option<String>, method: &str) -> Outcome {
|
||||||
match value {
|
match value {
|
||||||
Some(s) => into_json(s),
|
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 {
|
fn into_json(s: String) -> Outcome {
|
||||||
serde_json::from_str(&s)
|
serde_json::from_str(&s).map_err(|e| {
|
||||||
.map_err(|e| fail(ErrorKind::Internal, format!("core produced invalid JSON: {e}")))
|
fail(
|
||||||
|
ErrorKind::Internal,
|
||||||
|
format!("core produced invalid JSON: {e}"),
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,9 @@ struct PrefetchPlanRequest {
|
||||||
pub(crate) fn provider_availability_plan_json(request_json: &str) -> Option<String> {
|
pub(crate) fn provider_availability_plan_json(request_json: &str) -> Option<String> {
|
||||||
let request = serde_json::from_str::<ProviderAvailabilityRequest>(request_json).ok()?;
|
let request = serde_json::from_str::<ProviderAvailabilityRequest>(request_json).ok()?;
|
||||||
let has_stremio_stream_addon = request.addons.iter().any(|addon| {
|
let has_stremio_stream_addon = request.addons.iter().any(|addon| {
|
||||||
addon
|
addon.get("manifest").is_some_and(|manifest| {
|
||||||
.get("manifest")
|
addon_protocol::supports_resource(&manifest.to_string(), "stream", None, None)
|
||||||
.is_some_and(|manifest| addon_protocol::supports_resource(&manifest.to_string(), "stream", None, None))
|
})
|
||||||
});
|
});
|
||||||
let plugin_names = stable_non_empty_strings(request.plugin_names);
|
let plugin_names = stable_non_empty_strings(request.plugin_names);
|
||||||
serde_json::to_string(&json!({
|
serde_json::to_string(&json!({
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,8 @@ pub(super) fn dispatch_resource(
|
||||||
id,
|
id,
|
||||||
extra: extra.unwrap_or(Value::Null),
|
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)]
|
vec![engine.effect(EffectKind::FetchAddonResource, generation, payload)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,11 @@ struct RefreshAuthTokenPayload {
|
||||||
profile: Value,
|
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);
|
let generation = engine.bump_generation(GenerationKey::Auth);
|
||||||
engine.state.auth = AuthState {
|
engine.state.auth = AuthState {
|
||||||
provider: provider.clone(),
|
provider: provider.clone(),
|
||||||
|
|
@ -50,7 +54,11 @@ pub(super) fn dispatch_flow(engine: &mut HeadlessEngine, provider: String, mode:
|
||||||
error: Value::Null,
|
error: Value::Null,
|
||||||
generation,
|
generation,
|
||||||
};
|
};
|
||||||
vec![engine.effect(EffectKind::RunAuthFlow, generation, RunAuthFlowPayload { provider, mode })]
|
vec![engine.effect(
|
||||||
|
EffectKind::RunAuthFlow,
|
||||||
|
generation,
|
||||||
|
RunAuthFlowPayload { provider, mode },
|
||||||
|
)]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn dispatch_exchange(
|
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);
|
let generation = engine.bump_generation(GenerationKey::Auth);
|
||||||
engine.state.auth = AuthState {
|
engine.state.auth = AuthState {
|
||||||
provider: provider.clone(),
|
provider: provider.clone(),
|
||||||
|
|
@ -91,7 +103,11 @@ pub(super) fn dispatch_token_refresh(engine: &mut HeadlessEngine, provider: Stri
|
||||||
error: Value::Null,
|
error: Value::Null,
|
||||||
generation,
|
generation,
|
||||||
};
|
};
|
||||||
vec![engine.effect(EffectKind::RefreshAuthToken, generation, RefreshAuthTokenPayload { provider, profile })]
|
vec![engine.effect(
|
||||||
|
EffectKind::RefreshAuthToken,
|
||||||
|
generation,
|
||||||
|
RefreshAuthTokenPayload { provider, profile },
|
||||||
|
)]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn complete(
|
pub(super) fn complete(
|
||||||
|
|
|
||||||
|
|
@ -86,20 +86,43 @@ pub(super) fn complete(
|
||||||
}
|
}
|
||||||
engine.state.calendar.is_loading = false;
|
engine.state.calendar.is_loading = false;
|
||||||
if result.status == "ok" {
|
if result.status == "ok" {
|
||||||
let items = result.value.get("items").cloned().unwrap_or_else(|| result.value.clone());
|
let items = result
|
||||||
let local_items = result.value.get("localItems").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.value
|
||||||
let external_items = result.value.get("externalItems").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.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.items = items.clone();
|
||||||
engine.state.calendar.local_items = local_items;
|
engine.state.calendar.local_items = local_items;
|
||||||
engine.state.calendar.external_items = external_items.clone();
|
engine.state.calendar.external_items = external_items.clone();
|
||||||
engine.state.calendar.error = Value::Null;
|
engine.state.calendar.error = Value::Null;
|
||||||
let profile = effect.payload.get("profile").cloned().unwrap_or(Value::Null);
|
let profile = effect
|
||||||
let profile_id = effect.payload.get("profileId").cloned().unwrap_or(Value::Null);
|
.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![
|
let mut follow_up = vec![
|
||||||
engine.effect(
|
engine.effect(
|
||||||
EffectKind::UpdateCalendarWidget,
|
EffectKind::UpdateCalendarWidget,
|
||||||
generation,
|
generation,
|
||||||
CalendarItemsPayload { profile: profile.clone(), items: items.clone() },
|
CalendarItemsPayload {
|
||||||
|
profile: profile.clone(),
|
||||||
|
items: items.clone(),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
engine.effect(
|
engine.effect(
|
||||||
EffectKind::NotifyReleasedEpisodes,
|
EffectKind::NotifyReleasedEpisodes,
|
||||||
|
|
@ -111,7 +134,10 @@ pub(super) fn complete(
|
||||||
follow_up.push(engine.effect(
|
follow_up.push(engine.effect(
|
||||||
EffectKind::ReplaceExternalContinueWatching,
|
EffectKind::ReplaceExternalContinueWatching,
|
||||||
generation,
|
generation,
|
||||||
ReplaceExternalContinueWatchingPayload { profile_id, items: external_items },
|
ReplaceExternalContinueWatchingPayload {
|
||||||
|
profile_id,
|
||||||
|
items: external_items,
|
||||||
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
follow_up
|
follow_up
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,11 @@ pub(super) enum AppAction {
|
||||||
language: Option<String>,
|
language: Option<String>,
|
||||||
force: Option<bool>,
|
force: Option<bool>,
|
||||||
},
|
},
|
||||||
|
#[serde(rename = "refreshContinueWatchingRequested")]
|
||||||
|
RefreshContinueWatchingRequested {
|
||||||
|
profile: Option<Value>,
|
||||||
|
language: Option<String>,
|
||||||
|
},
|
||||||
#[serde(rename = "libraryHydrateRequested")]
|
#[serde(rename = "libraryHydrateRequested")]
|
||||||
LibraryHydrateRequested { profile_id: Option<String> },
|
LibraryHydrateRequested { profile_id: Option<String> },
|
||||||
#[serde(rename = "toggleWatchlistRequested")]
|
#[serde(rename = "toggleWatchlistRequested")]
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use super::helpers::{
|
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::state::GenerationKey;
|
||||||
use super::{EffectResultInput, HeadlessEngine};
|
use super::{EffectResultInput, HeadlessEngine};
|
||||||
|
|
@ -216,7 +217,11 @@ pub(super) fn dispatch_load(
|
||||||
profile: profile.unwrap_or(Value::Null),
|
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 {
|
if !engine.state.detail.is_loading_streams {
|
||||||
return vec![];
|
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);
|
merged.extend(streams);
|
||||||
engine.state.detail.streams = serde_json::json!(merged);
|
engine.state.detail.streams = serde_json::json!(merged);
|
||||||
engine.state.detail.visible_streams =
|
engine.state.detail.visible_streams = visible_streams(
|
||||||
visible_streams(&engine.state.detail.streams, engine.state.detail.selected_addon.as_str());
|
&engine.state.detail.streams,
|
||||||
|
engine.state.detail.selected_addon.as_str(),
|
||||||
|
);
|
||||||
let mut all_addons: Vec<String> = engine
|
let mut all_addons: Vec<String> = engine
|
||||||
.state
|
.state
|
||||||
.detail
|
.detail
|
||||||
.available_addons
|
.available_addons
|
||||||
.as_array()
|
.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();
|
.unwrap_or_default();
|
||||||
for addon in available_addons {
|
for addon in available_addons {
|
||||||
if !all_addons.contains(&addon) {
|
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.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![]
|
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 selected = addon.and_then(|value| {
|
||||||
let trimmed = value.trim().to_string();
|
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.selected_addon = selected
|
||||||
engine.state.detail.visible_streams = visible_streams(&engine.state.detail.streams, selected.as_deref());
|
.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![]
|
vec![]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -436,28 +465,43 @@ pub(super) fn complete(
|
||||||
}
|
}
|
||||||
"readPlaybackProgress" => {
|
"readPlaybackProgress" => {
|
||||||
if generation == engine.state.runtime.get(GenerationKey::Detail) {
|
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" => {
|
"readDetailLocalState" => {
|
||||||
if generation == engine.state.runtime.get(GenerationKey::Detail) {
|
if generation == engine.state.runtime.get(GenerationKey::Detail) {
|
||||||
if result.status == "ok" {
|
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
|
engine.state.detail.local_watched_video_ids = result
|
||||||
.value
|
.value
|
||||||
.get("localWatchedVideoIds")
|
.get("localWatchedVideoIds")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| serde_json::json!([]));
|
.unwrap_or_else(|| serde_json::json!([]));
|
||||||
engine.state.detail.is_in_watchlist =
|
engine.state.detail.is_in_watchlist = result
|
||||||
result.value.get("isInWatchlist").cloned().unwrap_or_else(|| Value::Bool(false));
|
.value
|
||||||
engine.state.detail.feedback = result.value.get("feedback").cloned().unwrap_or(Value::Null);
|
.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
|
engine.state.detail.has_stream_providers = result
|
||||||
.value
|
.value
|
||||||
.get("hasStreamProviders")
|
.get("hasStreamProviders")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| Value::Bool(false));
|
.unwrap_or(Value::Bool(false));
|
||||||
engine.state.detail.user_addons =
|
engine.state.detail.user_addons = result
|
||||||
result.value.get("userAddons").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.value
|
||||||
|
.get("userAddons")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| serde_json::json!([]));
|
||||||
} else {
|
} else {
|
||||||
engine.state.detail.error = normalize_error(result.error.clone());
|
engine.state.detail.error = normalize_error(result.error.clone());
|
||||||
}
|
}
|
||||||
|
|
@ -466,16 +510,33 @@ pub(super) fn complete(
|
||||||
"fetchDetailSecondary" => {
|
"fetchDetailSecondary" => {
|
||||||
if generation == engine.state.runtime.get(GenerationKey::Detail) {
|
if generation == engine.state.runtime.get(GenerationKey::Detail) {
|
||||||
if result.status == "ok" {
|
if result.status == "ok" {
|
||||||
engine.state.detail.watched_video_ids =
|
engine.state.detail.watched_video_ids = result
|
||||||
result.value.get("watchedVideoIds").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.value
|
||||||
engine.state.detail.similar_items =
|
.get("watchedVideoIds")
|
||||||
result.value.get("similarItems").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.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) {
|
if value_array_is_empty(&engine.state.detail.trailers) {
|
||||||
engine.state.detail.trailers =
|
engine.state.detail.trailers = result
|
||||||
result.value.get("trailers").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.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.omdb_ratings = result
|
||||||
engine.state.detail.fanart_artwork = result.value.get("fanartArtwork").cloned().unwrap_or(Value::Null);
|
.value
|
||||||
|
.get("omdbRatings")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
engine.state.detail.fanart_artwork = result
|
||||||
|
.value
|
||||||
|
.get("fanartArtwork")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
} else {
|
} else {
|
||||||
engine.state.detail.error = normalize_error(result.error.clone());
|
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) {
|
if generation == engine.state.runtime.get(GenerationKey::DetailStreams) {
|
||||||
engine.state.detail.is_loading_streams = false;
|
engine.state.detail.is_loading_streams = false;
|
||||||
if result.status == "ok" {
|
if result.status == "ok" {
|
||||||
engine.state.detail.streams =
|
engine.state.detail.streams = result
|
||||||
result.value.get("streams").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.value
|
||||||
|
.get("streams")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| serde_json::json!([]));
|
||||||
engine.state.detail.selected_addon = Value::Null;
|
engine.state.detail.selected_addon = Value::Null;
|
||||||
engine.state.detail.visible_streams = engine.state.detail.streams.clone();
|
engine.state.detail.visible_streams = engine.state.detail.streams.clone();
|
||||||
engine.state.detail.available_addons =
|
engine.state.detail.available_addons = result
|
||||||
result.value.get("availableAddons").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.value
|
||||||
|
.get("availableAddons")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| serde_json::json!([]));
|
||||||
engine.state.detail.loading_addon_names = 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.resolved_request_id = result
|
||||||
engine.state.detail.has_stream_providers =
|
.value
|
||||||
result.value.get("hasStreamProviders").cloned().unwrap_or_else(|| Value::Bool(false));
|
.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;
|
engine.state.detail.streams_error = Value::Null;
|
||||||
} else {
|
} else {
|
||||||
engine.state.detail.streams_error = normalize_error(result.error.clone());
|
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) {
|
if generation == engine.state.runtime.get(GenerationKey::Detail) {
|
||||||
engine.state.detail.season_loading = Value::Null;
|
engine.state.detail.season_loading = Value::Null;
|
||||||
if result.status == "ok" {
|
if result.status == "ok" {
|
||||||
engine.state.detail.season_episodes =
|
engine.state.detail.season_episodes = result
|
||||||
result.value.get("episodes").cloned().unwrap_or_else(|| result.value.clone());
|
.value
|
||||||
|
.get("episodes")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| result.value.clone());
|
||||||
engine.state.detail.error = Value::Null;
|
engine.state.detail.error = Value::Null;
|
||||||
} else {
|
} else {
|
||||||
engine.state.detail.error = normalize_error(result.error.clone());
|
engine.state.detail.error = normalize_error(result.error.clone());
|
||||||
|
|
|
||||||
|
|
@ -127,10 +127,16 @@ pub(super) fn complete(
|
||||||
"readDiscoverCatalogFilters" => {
|
"readDiscoverCatalogFilters" => {
|
||||||
if generation == engine.state.runtime.get(GenerationKey::Discover) {
|
if generation == engine.state.runtime.get(GenerationKey::Discover) {
|
||||||
if result.status == "ok" {
|
if result.status == "ok" {
|
||||||
engine.state.discover.catalogs =
|
engine.state.discover.catalogs = result
|
||||||
result.value.get("catalogs").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.value
|
||||||
engine.state.discover.genres =
|
.get("catalogs")
|
||||||
result.value.get("genres").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.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;
|
engine.state.discover.error = Value::Null;
|
||||||
} else {
|
} else {
|
||||||
engine.state.discover.error = normalize_error(result.error.clone());
|
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() {
|
if !target.is_array() {
|
||||||
*target = json!([]);
|
*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
|
if let Some(existing) = items
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|existing| existing[key].as_str() == Some(value))
|
.find(|existing| existing[key].as_str() == Some(value))
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,33 @@ struct FetchCatalogPagePayload {
|
||||||
search: Option<String>,
|
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) {
|
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() {
|
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));
|
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(
|
pub(super) fn dispatch_catalog_page(
|
||||||
engine: &mut HeadlessEngine,
|
engine: &mut HeadlessEngine,
|
||||||
category_id: String,
|
category_id: String,
|
||||||
|
|
@ -185,27 +213,47 @@ pub(super) fn complete(
|
||||||
result: &EffectResultInput,
|
result: &EffectResultInput,
|
||||||
) -> Vec<EffectEnvelope> {
|
) -> Vec<EffectEnvelope> {
|
||||||
match effect_type {
|
match effect_type {
|
||||||
|
"refreshContinueWatching" => {
|
||||||
|
if result.status == "ok" {
|
||||||
|
if let Some(cw) = result.value.get("continueWatching") {
|
||||||
|
engine.state.home.continue_watching = cw.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
"readHomeBootstrap" => {
|
"readHomeBootstrap" => {
|
||||||
if generation == engine.state.runtime.get(GenerationKey::Home) {
|
if generation == engine.state.runtime.get(GenerationKey::Home) {
|
||||||
engine.state.home.is_loading = false;
|
engine.state.home.is_loading = false;
|
||||||
if result.status == "ok" {
|
if result.status == "ok" {
|
||||||
engine.state.home.categories =
|
engine.state.home.categories = result
|
||||||
result.value.get("categories").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.value
|
||||||
|
.get("categories")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| serde_json::json!([]));
|
||||||
engine.state.home.continue_watching = result
|
engine.state.home.continue_watching = result
|
||||||
.value
|
.value
|
||||||
.get("continueWatching")
|
.get("continueWatching")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| serde_json::json!([]));
|
.unwrap_or_else(|| serde_json::json!([]));
|
||||||
engine.state.home.watchlist =
|
engine.state.home.watchlist = result
|
||||||
result.value.get("watchlist").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.value
|
||||||
engine.state.home.user_addons =
|
.get("watchlist")
|
||||||
result.value.get("userAddons").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.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
|
engine.state.home.metadata_feeds = result
|
||||||
.value
|
.value
|
||||||
.get("metadataFeeds")
|
.get("metadataFeeds")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| serde_json::json!([]));
|
.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;
|
engine.state.home.error = Value::Null;
|
||||||
} else {
|
} else {
|
||||||
engine.state.home.error = normalize_error(result.error.clone());
|
engine.state.home.error = normalize_error(result.error.clone());
|
||||||
|
|
@ -218,7 +266,11 @@ pub(super) fn complete(
|
||||||
if result.status == "ok" {
|
if result.status == "ok" {
|
||||||
player::complete_direct_playback(engine, result.value.clone(), Value::Null);
|
player::complete_direct_playback(engine, result.value.clone(), Value::Null);
|
||||||
} else {
|
} 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) {
|
if generation == engine.state.runtime.get(GenerationKey::Home) {
|
||||||
engine.state.home.paging.is_loading = false;
|
engine.state.home.paging.is_loading = false;
|
||||||
if result.status == "ok" {
|
if result.status == "ok" {
|
||||||
engine.state.home.paging.items =
|
engine.state.home.paging.items = result
|
||||||
result.value.get("items").cloned().unwrap_or_else(|| result.value.clone());
|
.value
|
||||||
|
.get("items")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| result.value.clone());
|
||||||
engine.state.home.paging.error = Value::Null;
|
engine.state.home.paging.error = Value::Null;
|
||||||
} else {
|
} else {
|
||||||
engine.state.home.paging.error = normalize_error(result.error.clone());
|
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();
|
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);
|
profile::activate(engine, profile);
|
||||||
vec![]
|
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 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.active_profile_id = resolved_profile_id.clone();
|
||||||
engine.state.library.is_loading = true;
|
engine.state.library.is_loading = true;
|
||||||
engine.state.library.error = Value::Null;
|
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(
|
vec![engine.effect(
|
||||||
EffectKind::ReadLibraryState,
|
EffectKind::ReadLibraryState,
|
||||||
generation,
|
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 generation = engine.bump_generation(GenerationKey::Library);
|
||||||
let profile_id = active_profile_id(&engine.state, &Value::Null);
|
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);
|
let command_value = serde_json::to_value(&command).unwrap_or(Value::Null);
|
||||||
engine.state.library.last_command = command_value.clone();
|
engine.state.library.last_command = command_value.clone();
|
||||||
vec![engine.effect(
|
vec![engine.effect(
|
||||||
EffectKind::WriteLibraryCommand,
|
EffectKind::WriteLibraryCommand,
|
||||||
generation,
|
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 generation = engine.bump_generation(GenerationKey::Library);
|
||||||
let profile_id = active_profile_id(&engine.state, &Value::Null);
|
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);
|
let command_value = serde_json::to_value(&command).unwrap_or(Value::Null);
|
||||||
engine.state.library.last_command = command_value.clone();
|
engine.state.library.last_command = command_value.clone();
|
||||||
vec![engine.effect(
|
vec![engine.effect(
|
||||||
EffectKind::WriteLibraryCommand,
|
EffectKind::WriteLibraryCommand,
|
||||||
generation,
|
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);
|
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);
|
let generation = engine.bump_generation(GenerationKey::Library);
|
||||||
vec![engine.effect(
|
vec![engine.effect(
|
||||||
EffectKind::ClearPlaybackProgress,
|
EffectKind::ClearPlaybackProgress,
|
||||||
generation,
|
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_audio_language,
|
||||||
last_subtitle_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(
|
vec![engine.effect(
|
||||||
EffectKind::WritePlaybackProgress,
|
EffectKind::WritePlaybackProgress,
|
||||||
generation,
|
generation,
|
||||||
|
|
@ -245,15 +291,15 @@ pub(super) fn dispatch_mark_watched(
|
||||||
let generation = engine.bump_generation(GenerationKey::Library);
|
let generation = engine.bump_generation(GenerationKey::Library);
|
||||||
let profile_id = active_profile_id(&engine.state, &Value::Null);
|
let profile_id = active_profile_id(&engine.state, &Value::Null);
|
||||||
let watched_value = watched.unwrap_or(true);
|
let watched_value = watched.unwrap_or(true);
|
||||||
let clean_video_ids: Vec<String> = video_ids.into_iter().filter(|value| !value.trim().is_empty()).fold(
|
let clean_video_ids: Vec<String> = video_ids
|
||||||
Vec::new(),
|
.into_iter()
|
||||||
|mut acc, value| {
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.fold(Vec::new(), |mut acc, value| {
|
||||||
if !acc.contains(&value) {
|
if !acc.contains(&value) {
|
||||||
acc.push(value);
|
acc.push(value);
|
||||||
}
|
}
|
||||||
acc
|
acc
|
||||||
},
|
});
|
||||||
);
|
|
||||||
let command = MarkWatchedCommand {
|
let command = MarkWatchedCommand {
|
||||||
kind: "markWatched",
|
kind: "markWatched",
|
||||||
series_id,
|
series_id,
|
||||||
|
|
@ -265,7 +311,10 @@ pub(super) fn dispatch_mark_watched(
|
||||||
let mut effects = vec![engine.effect(
|
let mut effects = vec![engine.effect(
|
||||||
EffectKind::WriteLibraryCommand,
|
EffectKind::WriteLibraryCommand,
|
||||||
generation,
|
generation,
|
||||||
WriteLibraryCommandPayload { profile_id, command: command_value },
|
WriteLibraryCommandPayload {
|
||||||
|
profile_id,
|
||||||
|
command: command_value,
|
||||||
|
},
|
||||||
)];
|
)];
|
||||||
if should_sync_watched_state(profile.as_ref(), meta.as_ref()) {
|
if should_sync_watched_state(profile.as_ref(), meta.as_ref()) {
|
||||||
effects.push(engine.effect(
|
effects.push(engine.effect(
|
||||||
|
|
@ -293,18 +342,36 @@ pub(super) fn complete(
|
||||||
if generation == engine.state.runtime.get(GenerationKey::Library) {
|
if generation == engine.state.runtime.get(GenerationKey::Library) {
|
||||||
engine.state.library.is_loading = false;
|
engine.state.library.is_loading = false;
|
||||||
if result.status == "ok" {
|
if result.status == "ok" {
|
||||||
engine.state.library.watchlist =
|
engine.state.library.watchlist = result
|
||||||
result.value.get("watchlist").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.value
|
||||||
engine.state.library.continue_watching =
|
.get("watchlist")
|
||||||
result.value.get("continueWatching").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.cloned()
|
||||||
engine.state.library.liked =
|
.unwrap_or_else(|| serde_json::json!([]));
|
||||||
result.value.get("liked").cloned().unwrap_or_else(|| serde_json::json!([]));
|
engine.state.library.continue_watching = result
|
||||||
engine.state.library.watched =
|
.value
|
||||||
result.value.get("watched").cloned().unwrap_or_else(|| serde_json::json!({}));
|
.get("continueWatching")
|
||||||
engine.state.library.dropped =
|
.cloned()
|
||||||
result.value.get("dropped").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.unwrap_or_else(|| serde_json::json!([]));
|
||||||
engine.state.library.completed =
|
engine.state.library.liked = result
|
||||||
result.value.get("completed").cloned().unwrap_or_else(|| serde_json::json!([]));
|
.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;
|
engine.state.library.error = Value::Null;
|
||||||
} else {
|
} else {
|
||||||
engine.state.library.error = normalize_error(result.error.clone());
|
engine.state.library.error = normalize_error(result.error.clone());
|
||||||
|
|
@ -316,10 +383,22 @@ pub(super) fn complete(
|
||||||
if result.status == "ok" {
|
if result.status == "ok" {
|
||||||
engine.state.library.last_write = result.value.clone();
|
engine.state.library.last_write = result.value.clone();
|
||||||
engine.state.library.last_write_error = Value::Null;
|
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);
|
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);
|
detail::set_local_watched_video_ids(engine, value);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -330,7 +409,10 @@ pub(super) fn complete(
|
||||||
"writeFeedback" => {
|
"writeFeedback" => {
|
||||||
if generation == engine.state.runtime.get(GenerationKey::Library) {
|
if generation == engine.state.runtime.get(GenerationKey::Library) {
|
||||||
if result.status == "ok" {
|
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;
|
engine.state.library.last_write_error = Value::Null;
|
||||||
} else {
|
} else {
|
||||||
engine.state.library.last_write_error = normalize_error(result.error.clone());
|
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;
|
engine.state.library.last_write_error = Value::Null;
|
||||||
// Remove the dropped item from home.continueWatching so stale state
|
// Remove the dropped item from home.continueWatching so stale state
|
||||||
// doesn't reappear when the user navigates back to the home screen.
|
// 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);
|
home::remove_from_continue_watching(engine, dropped_id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -355,7 +438,8 @@ pub(super) fn complete(
|
||||||
"writePlaybackProgress" => {
|
"writePlaybackProgress" => {
|
||||||
if generation == engine.state.runtime.get(GenerationKey::Library) {
|
if generation == engine.state.runtime.get(GenerationKey::Library) {
|
||||||
if result.status == "ok" {
|
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.pending_playback_progress = Value::Null;
|
||||||
engine.state.library.last_write_error = Value::Null;
|
engine.state.library.last_write_error = Value::Null;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -369,7 +453,8 @@ pub(super) fn complete(
|
||||||
engine.state.library.last_watched_sync = result.value.clone();
|
engine.state.library.last_watched_sync = result.value.clone();
|
||||||
engine.state.library.last_watched_sync_error = Value::Null;
|
engine.state.library.last_watched_sync_error = Value::Null;
|
||||||
} else {
|
} 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();
|
static ENGINES: OnceLock<Mutex<HashMap<u64, HeadlessEngine>>> = OnceLock::new();
|
||||||
|
|
||||||
pub(crate) fn create_headless_engine(initial_json: &str) -> u64 {
|
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) {
|
if let Ok(initial_state) = serde_json::from_str::<EngineState>(initial_json) {
|
||||||
engine.state = initial_state;
|
engine.state = initial_state;
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +129,15 @@ impl HeadlessEngine {
|
||||||
source_addon_transport_url,
|
source_addon_transport_url,
|
||||||
source_addon_catalog_type,
|
source_addon_catalog_type,
|
||||||
profile,
|
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 {
|
AppAction::DetailLocalStateRequested {
|
||||||
primary_id,
|
primary_id,
|
||||||
fallback_id,
|
fallback_id,
|
||||||
|
|
@ -303,6 +314,9 @@ impl HeadlessEngine {
|
||||||
language,
|
language,
|
||||||
force,
|
force,
|
||||||
} => home::dispatch_load(self, profile, 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 } => {
|
AppAction::LibraryHydrateRequested { profile_id } => {
|
||||||
library::dispatch_hydrate(self, profile_id)
|
library::dispatch_hydrate(self, profile_id)
|
||||||
}
|
}
|
||||||
|
|
@ -508,7 +522,9 @@ impl HeadlessEngine {
|
||||||
let Some(kind) = EffectKind::from_str(&effect.kind) else {
|
let Some(kind) = EffectKind::from_str(&effect.kind) else {
|
||||||
return vec![];
|
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.delivered_effect_ids.remove(&result.effect_id);
|
||||||
self.effect_created_at.remove(&result.effect_id);
|
self.effect_created_at.remove(&result.effect_id);
|
||||||
let effect_type = kind.as_str();
|
let effect_type = kind.as_str();
|
||||||
|
|
@ -522,7 +538,9 @@ impl HeadlessEngine {
|
||||||
| EffectKind::PrefetchDetailStreams
|
| EffectKind::PrefetchDetailStreams
|
||||||
| EffectKind::FetchDetailStreams
|
| EffectKind::FetchDetailStreams
|
||||||
| EffectKind::FetchMetaDetailLookup
|
| EffectKind::FetchMetaDetailLookup
|
||||||
| EffectKind::FetchSeasonEpisodes => detail::complete(self, effect_type, generation, &result),
|
| EffectKind::FetchSeasonEpisodes => {
|
||||||
|
detail::complete(self, effect_type, generation, &result)
|
||||||
|
}
|
||||||
|
|
||||||
EffectKind::LoadStreams
|
EffectKind::LoadStreams
|
||||||
| EffectKind::StartTorrentStream
|
| EffectKind::StartTorrentStream
|
||||||
|
|
@ -531,22 +549,31 @@ impl HeadlessEngine {
|
||||||
| EffectKind::FetchIntroSegments
|
| EffectKind::FetchIntroSegments
|
||||||
| EffectKind::ResolveIntroImdbId
|
| EffectKind::ResolveIntroImdbId
|
||||||
| EffectKind::FetchSubtitles
|
| EffectKind::FetchSubtitles
|
||||||
| EffectKind::PrefetchNextEpisodeStreams => player::complete(self, effect_type, generation, &result),
|
| EffectKind::PrefetchNextEpisodeStreams => {
|
||||||
|
player::complete(self, effect_type, generation, &result)
|
||||||
|
}
|
||||||
|
|
||||||
EffectKind::ReadHomeBootstrap
|
EffectKind::ReadHomeBootstrap
|
||||||
|
| EffectKind::RefreshContinueWatching
|
||||||
| EffectKind::PrepareDirectPlayback
|
| EffectKind::PrepareDirectPlayback
|
||||||
| EffectKind::FetchCatalogPage => home::complete(self, effect_type, generation, &result),
|
| EffectKind::FetchCatalogPage => {
|
||||||
|
home::complete(self, effect_type, generation, &result)
|
||||||
|
}
|
||||||
|
|
||||||
EffectKind::ReadLibraryState
|
EffectKind::ReadLibraryState
|
||||||
| EffectKind::WriteLibraryCommand
|
| EffectKind::WriteLibraryCommand
|
||||||
| EffectKind::WriteFeedback
|
| EffectKind::WriteFeedback
|
||||||
| EffectKind::ClearPlaybackProgress
|
| EffectKind::ClearPlaybackProgress
|
||||||
| EffectKind::WritePlaybackProgress
|
| EffectKind::WritePlaybackProgress
|
||||||
| EffectKind::SyncWatchedState => library::complete(self, effect_type, generation, &result),
|
| EffectKind::SyncWatchedState => {
|
||||||
|
library::complete(self, effect_type, generation, &result)
|
||||||
|
}
|
||||||
|
|
||||||
EffectKind::FetchAddonManifest
|
EffectKind::FetchAddonManifest
|
||||||
| EffectKind::RefreshInstalledAddons
|
| 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),
|
EffectKind::RunSearch => search::complete(self, generation, &result),
|
||||||
|
|
||||||
|
|
@ -564,7 +591,9 @@ impl HeadlessEngine {
|
||||||
sync::complete(self, effect_type, generation, &result)
|
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)
|
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);
|
let payload = serde_json::to_value(&payload).unwrap_or(Value::Null);
|
||||||
self.effect_raw(kind.as_str(), generation, payload)
|
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
|
// 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
|
// it for that long would stall unrelated IPC calls behind it. Callers clone what they
|
||||||
// need and drop the lock before calling this.
|
// 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 {
|
serde_json::to_string(&DispatchResult {
|
||||||
state: StatePatch::diff(before, after),
|
state: StatePatch::diff(before, after),
|
||||||
effects,
|
effects,
|
||||||
|
|
@ -667,7 +705,9 @@ fn engines() -> &'static Mutex<HashMap<u64, HeadlessEngine>> {
|
||||||
// Recovering the guard accepts that one engine's state might be left
|
// 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.
|
// mid-update, which is still far better than every other handle going dark.
|
||||||
fn lock_engines() -> std::sync::MutexGuard<'static, HashMap<u64, HeadlessEngine>> {
|
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)]
|
#[cfg(test)]
|
||||||
|
|
@ -1118,7 +1158,13 @@ mod tests {
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
.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(
|
let requested: Value = serde_json::from_str(
|
||||||
&headless_engine_dispatch_json(
|
&headless_engine_dispatch_json(
|
||||||
|
|
@ -1144,7 +1190,9 @@ mod tests {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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.len(), 1);
|
||||||
assert_eq!(continue_watching[0]["id"], "tt2");
|
assert_eq!(continue_watching[0]["id"], "tt2");
|
||||||
assert!(destroy_headless_engine(handle));
|
assert!(destroy_headless_engine(handle));
|
||||||
|
|
@ -1454,9 +1502,18 @@ mod tests {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(prefetch_requested["effects"][0]["type"], "prefetchNextEpisodeStreams");
|
assert_eq!(
|
||||||
assert_eq!(prefetch_requested["effects"][0]["payload"]["nextVideoId"], "tt1:1:2");
|
prefetch_requested["effects"][0]["type"],
|
||||||
assert_eq!(prefetch_requested["state"]["player"]["prefetchingNextVideoId"], "tt1:1:2");
|
"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.
|
// Duplicate card-shown dispatch must not change prefetching state.
|
||||||
let duplicate: Value = serde_json::from_str(
|
let duplicate: Value = serde_json::from_str(
|
||||||
|
|
@ -1491,8 +1548,14 @@ mod tests {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(prefetch_done["state"]["player"]["prefetchedNextEpisode"]["videoId"], "tt1:1:2");
|
assert_eq!(
|
||||||
assert_eq!(prefetch_done["state"]["player"]["prefetchedNextEpisode"]["streams"][0]["title"], "S");
|
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());
|
assert!(prefetch_done["state"]["player"]["prefetchingNextVideoId"].is_null());
|
||||||
|
|
||||||
// 3. User navigates to ep2 — load streams without passing initial_streams.
|
// 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 {
|
engine.state.navigation = NavigationState {
|
||||||
route,
|
route,
|
||||||
params: params.unwrap_or(Value::Null),
|
params: params.unwrap_or(Value::Null),
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ struct EnqueueOfflineDownloadPayload {
|
||||||
language: Option<String>,
|
language: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(super) fn dispatch(
|
pub(super) fn dispatch(
|
||||||
engine: &mut HeadlessEngine,
|
engine: &mut HeadlessEngine,
|
||||||
meta: Value,
|
meta: Value,
|
||||||
|
|
@ -49,7 +50,11 @@ pub(super) fn dispatch(
|
||||||
vec![engine.effect(EffectKind::EnqueueOfflineDownload, generation, payload)]
|
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 generation == engine.state.runtime.get(GenerationKey::Offline) {
|
||||||
if result.status == "ok" {
|
if result.status == "ok" {
|
||||||
engine.state.offline.last_enqueued = result.value.clone();
|
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.
|
// behavior of overwriting `engine.state["player"]` with the flow's state outright.
|
||||||
fn from_flow_state(flow_state: PlayerFlowState) -> Self {
|
fn from_flow_state(flow_state: PlayerFlowState) -> Self {
|
||||||
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_streams: Value::Array(flow_state.current_streams),
|
||||||
current_stream_index: flow_state.current_stream_index as i64,
|
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,
|
zero_speed_ticks: flow_state.zero_speed_ticks as i64,
|
||||||
is_buffering: flow_state.is_buffering,
|
is_buffering: flow_state.is_buffering,
|
||||||
is_video_rendered: flow_state.is_video_rendered,
|
is_video_rendered: flow_state.is_video_rendered,
|
||||||
player_error: flow_state.player_error.map(Value::String).unwrap_or(Value::Null),
|
player_error: flow_state
|
||||||
preferred_binge_group: flow_state.preferred_binge_group.map(Value::String).unwrap_or(Value::Null),
|
.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()
|
..Self::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -207,8 +219,15 @@ pub(super) fn dispatch_next_episode_prefetch(
|
||||||
language: Option<String>,
|
language: Option<String>,
|
||||||
profile: Option<Value>,
|
profile: Option<Value>,
|
||||||
) -> Vec<EffectEnvelope> {
|
) -> Vec<EffectEnvelope> {
|
||||||
let already_prefetching = engine.state.player.prefetching_next_video_id.as_str().is_some_and(|v| v == next_video_id);
|
let already_prefetching = engine
|
||||||
let already_cached = engine.state.player.prefetched_next_episode["videoId"].as_str().is_some_and(|v| v == next_video_id);
|
.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 {
|
if already_prefetching || already_cached {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
|
|
@ -263,7 +282,10 @@ pub(super) fn dispatch_load_streams(
|
||||||
let prefetched = engine.state.player.prefetched_next_episode.clone();
|
let prefetched = engine.state.player.prefetched_next_episode.clone();
|
||||||
let cached_video_id = prefetched["videoId"].as_str().map(str::to_string);
|
let cached_video_id = prefetched["videoId"].as_str().map(str::to_string);
|
||||||
if cached_video_id.is_some() && cached_video_id == current_video_id {
|
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;
|
effective_initial_video_id = cached_video_id;
|
||||||
engine.state.player.prefetched_next_episode = Value::Null;
|
engine.state.player.prefetched_next_episode = Value::Null;
|
||||||
}
|
}
|
||||||
|
|
@ -303,12 +325,22 @@ pub(super) fn dispatch_load_streams(
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|effect| {
|
.map(|effect| {
|
||||||
let mut payload = serde_json::to_value(&effect).unwrap_or(Value::Null);
|
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 kind == "loadStreams" {
|
||||||
if let Value::Object(map) = &mut payload {
|
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("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("year".to_string(), pending_value["year"].clone());
|
||||||
map.insert("language".to_string(), pending_value["language"].clone());
|
map.insert("language".to_string(), pending_value["language"].clone());
|
||||||
map.insert("profile".to_string(), pending_value["profile"].clone());
|
map.insert("profile".to_string(), pending_value["profile"].clone());
|
||||||
|
|
@ -319,6 +351,7 @@ pub(super) fn dispatch_load_streams(
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(super) fn dispatch_streams_loaded(
|
pub(super) fn dispatch_streams_loaded(
|
||||||
engine: &mut HeadlessEngine,
|
engine: &mut HeadlessEngine,
|
||||||
streams: Vec<Value>,
|
streams: Vec<Value>,
|
||||||
|
|
@ -348,8 +381,13 @@ pub(super) fn dispatch_streams_loaded(
|
||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn dispatch_streams_failed(engine: &mut HeadlessEngine, err_code: Option<String>) -> Vec<EffectEnvelope> {
|
pub(super) fn dispatch_streams_failed(
|
||||||
let action = PlayerFlowAction::StreamsFailed { error_code: err_code };
|
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 mut flow_state = engine.state.player.to_flow_state();
|
||||||
let _ = player_flow::dispatch(&mut flow_state, action);
|
let _ = player_flow::dispatch(&mut flow_state, action);
|
||||||
engine.state.player = PlayerState::from_flow_state(flow_state);
|
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) {
|
if stream_policy::is_torrent_playback_url(&url) {
|
||||||
let stream_value = stream.unwrap_or(Value::Null);
|
let stream_value = stream.unwrap_or(Value::Null);
|
||||||
let file_idx = stream_value["fileIdx"].as_i64();
|
let file_idx = stream_value["fileIdx"].as_i64();
|
||||||
let preferred_filename = stream_value["effectiveFilename"].as_str().map(ToString::to_string);
|
let preferred_filename = stream_value["effectiveFilename"]
|
||||||
let sources = stream_value["sources"].as_array().cloned().unwrap_or_default();
|
.as_str()
|
||||||
|
.map(ToString::to_string);
|
||||||
|
let sources = stream_value["sources"]
|
||||||
|
.as_array()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
vec![engine.effect(
|
vec![engine.effect(
|
||||||
EffectKind::StartTorrentStream,
|
EffectKind::StartTorrentStream,
|
||||||
generation,
|
generation,
|
||||||
|
|
@ -390,7 +433,13 @@ pub(super) fn dispatch_resolve_playback(
|
||||||
} else {
|
} else {
|
||||||
engine.state.player.resolved_url = engine.state.player.current_url.clone();
|
engine.state.player.resolved_url = engine.state.player.current_url.clone();
|
||||||
engine.state.player.is_buffering = false;
|
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(
|
vec![engine.effect(
|
||||||
EffectKind::FetchIntroSegments,
|
EffectKind::FetchIntroSegments,
|
||||||
generation,
|
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);
|
let generation = engine.bump_generation(GenerationKey::Intro);
|
||||||
vec![engine.effect(
|
vec![engine.effect(
|
||||||
EffectKind::ResolveIntroImdbId,
|
EffectKind::ResolveIntroImdbId,
|
||||||
generation,
|
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(
|
vec![engine.effect(
|
||||||
EffectKind::FetchSubtitles,
|
EffectKind::FetchSubtitles,
|
||||||
generation,
|
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),
|
Some(pending["initialStreamIndex"].as_i64().unwrap_or(0) as i32),
|
||||||
pending["savedUrl"].as_str().map(ToString::to_string),
|
pending["savedUrl"].as_str().map(ToString::to_string),
|
||||||
pending["savedTitle"].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["regexPattern"].as_str().map(ToString::to_string),
|
||||||
pending["preferredBingeGroup"].as_str().map(ToString::to_string),
|
pending["preferredBingeGroup"]
|
||||||
|
.as_str()
|
||||||
|
.map(ToString::to_string),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
dispatch_streams_failed(engine, Some(error_code(&result.error)));
|
dispatch_streams_failed(engine, Some(error_code(&result.error)));
|
||||||
|
|
@ -491,7 +565,8 @@ pub(super) fn complete(
|
||||||
"startTorrentStream" => {
|
"startTorrentStream" => {
|
||||||
if generation == engine.state.runtime.get(GenerationKey::Player) {
|
if generation == engine.state.runtime.get(GenerationKey::Player) {
|
||||||
if result.status == "ok" {
|
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.is_buffering = false;
|
||||||
engine.state.player.player_error = Value::Null;
|
engine.state.player.player_error = Value::Null;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -512,7 +587,9 @@ pub(super) fn complete(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"stopTorrent" => {
|
"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());
|
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) {
|
if generation == engine.state.runtime.get(GenerationKey::Player) {
|
||||||
engine.state.player.subtitle_loading = false;
|
engine.state.player.subtitle_loading = false;
|
||||||
if result.status == "ok" {
|
if result.status == "ok" {
|
||||||
engine.state.player.subtitles =
|
engine.state.player.subtitles = result
|
||||||
result.value.get("subtitles").cloned().unwrap_or_else(|| result.value.clone());
|
.value
|
||||||
|
.get("subtitles")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| result.value.clone());
|
||||||
engine.state.player.player_error = Value::Null;
|
engine.state.player.player_error = Value::Null;
|
||||||
} else {
|
} else {
|
||||||
engine.state.player.player_error = Value::String(error_code(&result.error));
|
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) {
|
if generation == engine.state.runtime.get(GenerationKey::Search) {
|
||||||
engine.state.search.is_loading = false;
|
engine.state.search.is_loading = false;
|
||||||
if result.status == "ok" {
|
if result.status == "ok" {
|
||||||
|
|
@ -70,7 +74,8 @@ pub(super) fn complete(engine: &mut HeadlessEngine, generation: u64, result: &Ef
|
||||||
.get("categories")
|
.get("categories")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| serde_json::json!([]));
|
.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;
|
engine.state.search.error = Value::Null;
|
||||||
} else {
|
} else {
|
||||||
engine.state.search.error = normalize_error(result.error.clone());
|
engine.state.search.error = normalize_error(result.error.clone());
|
||||||
|
|
|
||||||
|
|
@ -21,16 +21,28 @@ struct WriteSettingsPayload {
|
||||||
value: Value,
|
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);
|
let generation = engine.bump_generation(GenerationKey::Settings);
|
||||||
if !engine.state.settings.values.is_object() {
|
if !engine.state.settings.values.is_object() {
|
||||||
engine.state.settings.values = serde_json::json!({});
|
engine.state.settings.values = serde_json::json!({});
|
||||||
}
|
}
|
||||||
engine.state.settings.values[key.as_str()] = value.clone();
|
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 generation == engine.state.runtime.get(GenerationKey::Settings) {
|
||||||
if result.status != "ok" {
|
if result.status != "ok" {
|
||||||
engine.state.settings.last_write_error = normalize_error(result.error.clone());
|
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) {
|
if generation == engine.state.runtime.get(GenerationKey::Sync) {
|
||||||
engine.state.sync.is_loading = false;
|
engine.state.sync.is_loading = false;
|
||||||
if result.status == "ok" {
|
if result.status == "ok" {
|
||||||
let updated_profile = result.value.get("profile").cloned().unwrap_or(Value::Null);
|
let updated_profile =
|
||||||
engine.state.sync.snapshot = result.value.get("snapshot").cloned().unwrap_or(Value::Null);
|
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() {
|
if !updated_profile.is_null() {
|
||||||
profile::update_active(engine, updated_profile);
|
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(kept);
|
||||||
output.extend(fallback);
|
output.extend(fallback);
|
||||||
let limit = 24 + output_pinned_count(&output);
|
let limit = 24 + output_pinned_count(&output);
|
||||||
let output = distinct_categories(output.into_iter())
|
let output = distinct_categories(output)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.take(limit)
|
.take(limit)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
@ -686,7 +686,7 @@ pub(crate) fn build_billboard_pool_json(
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut editorial = editorial_raw;
|
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)
|
let editorial: Vec<Value> = distinct_by_title_key(editorial)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.take(3)
|
.take(3)
|
||||||
|
|
@ -725,10 +725,8 @@ pub(crate) fn build_billboard_pool_json(
|
||||||
let final_pool: Vec<Value> = if preferred.len() >= 10 {
|
let final_pool: Vec<Value> = if preferred.len() >= 10 {
|
||||||
preferred.into_iter().take(10).collect()
|
preferred.into_iter().take(10).collect()
|
||||||
} else {
|
} else {
|
||||||
let preferred_keys: HashSet<String> =
|
let preferred_keys: HashSet<String> = preferred.iter().map(billboard_key_value).collect();
|
||||||
preferred.iter().map(billboard_key_value).collect();
|
let preferred_titles: HashSet<String> = preferred.iter().map(title_key_value).collect();
|
||||||
let preferred_titles: HashSet<String> =
|
|
||||||
preferred.iter().map(title_key_value).collect();
|
|
||||||
let extras = ranked.into_iter().filter(|m| {
|
let extras = ranked.into_iter().filter(|m| {
|
||||||
!preferred_keys.contains(&billboard_key_value(m))
|
!preferred_keys.contains(&billboard_key_value(m))
|
||||||
&& !preferred_titles.contains(&title_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,
|
today_iso: &str,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
|
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
|
||||||
let assign_rank = genre.map(|g| g.is_empty()).unwrap_or(true)
|
let assign_rank =
|
||||||
&& RANKED_CATALOG_IDS.contains(&catalog_id);
|
genre.map(|g| g.is_empty()).unwrap_or(true) && RANKED_CATALOG_IDS.contains(&catalog_id);
|
||||||
|
|
||||||
let mut rank: i64 = 0;
|
let mut rank: i64 = 0;
|
||||||
let result: Vec<Value> = items
|
let result: Vec<Value> = items
|
||||||
|
|
@ -787,12 +785,19 @@ pub(crate) fn normalize_home_catalog_items_json(
|
||||||
serde_json::to_string(&result).ok()
|
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 profile: Value = serde_json::from_str(profile_json).ok()?;
|
||||||
let collections = match profile.get("libraryCollections").and_then(Value::as_array) {
|
let collections =
|
||||||
Some(c) => c,
|
match profile.get("libraryCollections").and_then(Value::as_array) {
|
||||||
None => return serde_json::to_string(&json!({ "pinnedShelves": [], "regularShelves": [], "hiddenFolderCategories": [] })).ok(),
|
Some(c) => c,
|
||||||
};
|
None => return serde_json::to_string(
|
||||||
|
&json!({ "pinnedShelves": [], "regularShelves": [], "hiddenFolderCategories": [] }),
|
||||||
|
)
|
||||||
|
.ok(),
|
||||||
|
};
|
||||||
|
|
||||||
let mut pinned: Vec<Value> = Vec::new();
|
let mut pinned: Vec<Value> = Vec::new();
|
||||||
let mut regular: 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,
|
Some(o) => o,
|
||||||
None => continue,
|
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;
|
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() {
|
if folders.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -818,17 +831,28 @@ pub(crate) fn build_home_collection_shelves_json(profile_json: &str, addons_json
|
||||||
Some(o) => o,
|
Some(o) => o,
|
||||||
None => continue,
|
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() {
|
if folder_title.is_empty() {
|
||||||
continue;
|
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)
|
.map(str::to_string)
|
||||||
.unwrap_or_else(|| format!("col{ci}_f{fi}"));
|
.unwrap_or_else(|| format!("col{ci}_f{fi}"));
|
||||||
|
|
||||||
let resolved = resolve_folder_catalog_sources(folder, addons_json);
|
let resolved = resolve_folder_catalog_sources(folder, addons_json);
|
||||||
if !resolved.is_empty() {
|
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));
|
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;
|
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)
|
.map(str::to_string)
|
||||||
.unwrap_or_else(|| format!("col{ci}"));
|
.unwrap_or_else(|| format!("col{ci}"));
|
||||||
let shelf = json!({
|
let shelf = json!({
|
||||||
|
|
@ -859,7 +885,8 @@ pub(crate) fn build_home_collection_shelves_json(profile_json: &str, addons_json
|
||||||
"pinnedShelves": pinned,
|
"pinnedShelves": pinned,
|
||||||
"regularShelves": regular,
|
"regularShelves": regular,
|
||||||
"hiddenFolderCategories": hidden,
|
"hiddenFolderCategories": hidden,
|
||||||
})).ok()
|
}))
|
||||||
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
// A folder's catalog sources, preferring its explicit catalogSources list and
|
// 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) {
|
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 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 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) {
|
if let Some(g) = folder.get("genre").and_then(Value::as_str) {
|
||||||
entry["genre"] = Value::String(g.to_string());
|
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) {
|
if let Some(catalog_id) = folder.get("catalogId").and_then(Value::as_str) {
|
||||||
let src = json!({ "catalogId": catalog_id, "type": "movie" });
|
let src = json!({ "catalogId": catalog_id, "type": "movie" });
|
||||||
if let Some(t_url) = resolve_transport_url_json(&src.to_string(), addons_json) {
|
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) {
|
if let Some(g) = folder.get("genre").and_then(Value::as_str) {
|
||||||
entry["genre"] = Value::String(g.to_string());
|
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 {
|
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))
|
.or_else(|| folder.get("imageUrl").and_then(Value::as_str))
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
let bg_url = folder.get("heroBackdropUrl").and_then(Value::as_str).unwrap_or(img_url);
|
let bg_url = folder
|
||||||
let focus_gif_enabled = folder.get("focusGifEnabled").and_then(Value::as_bool).unwrap_or(true);
|
.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!({
|
let mut tile = json!({
|
||||||
"id": folder_id,
|
"id": folder_id,
|
||||||
|
|
@ -993,7 +1030,10 @@ mod tests {
|
||||||
assert_eq!(pinned.len(), 1);
|
assert_eq!(pinned.len(), 1);
|
||||||
assert_eq!(pinned[0]["id"], "col1");
|
assert_eq!(pinned[0]["id"], "col1");
|
||||||
assert_eq!(pinned[0]["items"][0]["id"], "f1");
|
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();
|
let hidden = result["hiddenFolderCategories"].as_array().unwrap();
|
||||||
assert_eq!(hidden.len(), 1);
|
assert_eq!(hidden.len(), 1);
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,28 @@ fn collect_segments(data: &Value) -> Vec<Value> {
|
||||||
result.push(seg);
|
result.push(seg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let start = number_from_keys(obj, &[
|
let start = number_from_keys(
|
||||||
&format!("{seg_type}Start"), &format!("{seg_type}_start"),
|
obj,
|
||||||
&format!("{seg_type}StartTime"), &format!("{seg_type}_start_time"),
|
&[
|
||||||
&format!("{seg_type}StartMs"), &format!("{seg_type}_start_ms"),
|
&format!("{seg_type}Start"),
|
||||||
]);
|
&format!("{seg_type}_start"),
|
||||||
let end = number_from_keys(obj, &[
|
&format!("{seg_type}StartTime"),
|
||||||
&format!("{seg_type}End"), &format!("{seg_type}_end"),
|
&format!("{seg_type}_start_time"),
|
||||||
&format!("{seg_type}EndTime"), &format!("{seg_type}_end_time"),
|
&format!("{seg_type}StartMs"),
|
||||||
&format!("{seg_type}EndMs"), &format!("{seg_type}_end_ms"),
|
&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) {
|
if let (Some(s), Some(e)) = (start, end) {
|
||||||
let start_ms = normalize_time(s);
|
let start_ms = normalize_time(s);
|
||||||
let end_ms = normalize_time(e);
|
let end_ms = normalize_time(e);
|
||||||
|
|
@ -46,36 +58,94 @@ fn collect_segments(data: &Value) -> Vec<Value> {
|
||||||
result.push(seg);
|
result.push(seg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.into_iter().filter(|s| {
|
result
|
||||||
let st = s.get("startTime").and_then(Value::as_i64).unwrap_or(0);
|
.into_iter()
|
||||||
let et = s.get("endTime").and_then(Value::as_i64).unwrap_or(0);
|
.filter(|s| {
|
||||||
et > st
|
let st = s.get("startTime").and_then(Value::as_i64).unwrap_or(0);
|
||||||
}).collect()
|
let et = s.get("endTime").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
et > st
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn segment_from_object(obj: &serde_json::Map<String, Value>) -> Option<Value> {
|
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 start = number_from_keys(
|
||||||
let end = number_from_keys(obj, &["endTime", "end", "to", "end_sec", "end_time", "endTimeMs", "end_ms", "endOffset"])?;
|
obj,
|
||||||
let raw_type = string_from_keys(obj, &["segment_type", "skip_type", "category", "name", "type"]).unwrap_or_else(|| "intro".to_string());
|
&[
|
||||||
|
"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 seg_type = normalize_skip_type(&raw_type);
|
||||||
let start_ms = normalize_time(start);
|
let start_ms = normalize_time(start);
|
||||||
let end_ms = normalize_time(end);
|
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))
|
Some(make_segment(seg_type, start_ms, end_ms))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn segment_from_object_with_type(value: &Value, fallback_type: &str) -> Option<Value> {
|
fn segment_from_object_with_type(value: &Value, fallback_type: &str) -> Option<Value> {
|
||||||
let obj = value.as_object()?;
|
let obj = value.as_object()?;
|
||||||
let start = number_from_keys(obj, &["startTime", "start", "from", "start_time", "start_sec", "startTimeMs", "start_ms"])?;
|
let start = number_from_keys(
|
||||||
let end = number_from_keys(obj, &["endTime", "end", "to", "end_time", "end_sec", "endTimeMs", "end_ms"])?;
|
obj,
|
||||||
let raw_type = string_from_keys(obj, &["type", "segment_type"]).unwrap_or_else(|| fallback_type.to_string());
|
&[
|
||||||
|
"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 seg_type = normalize_skip_type(&raw_type);
|
||||||
let start_ms = normalize_time(start);
|
let start_ms = normalize_time(start);
|
||||||
let end_ms = normalize_time(end);
|
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))
|
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> {
|
fn number_from_keys(obj: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<f64> {
|
||||||
for key in keys {
|
for key in keys {
|
||||||
match obj.get(*key) {
|
match obj.get(*key) {
|
||||||
Some(Value::Number(n)) => if let Some(f) = n.as_f64() { return Some(f); },
|
Some(Value::Number(n)) => {
|
||||||
Some(Value::String(s)) => if let Ok(f) = s.trim().parse::<f64>() { return Some(f); },
|
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 {
|
for key in keys {
|
||||||
if let Some(Value::String(s)) = obj.get(*key) {
|
if let Some(Value::String(s)) = obj.get(*key) {
|
||||||
let t = s.trim();
|
let t = s.trim();
|
||||||
if !t.is_empty() { return Some(t.to_string()); }
|
if !t.is_empty() {
|
||||||
|
return Some(t.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_time(value: f64) -> i64 {
|
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 {
|
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> {
|
pub(crate) fn parse_aniskip_results_json(results_json: &str) -> Option<String> {
|
||||||
let results: Value = serde_json::from_str(results_json).ok()?;
|
let results: Value = serde_json::from_str(results_json).ok()?;
|
||||||
let items = results.get("results").and_then(Value::as_array)?;
|
let items = results.get("results").and_then(Value::as_array)?;
|
||||||
let segments: Vec<Value> = items.iter().filter_map(|item| {
|
let segments: Vec<Value> = items
|
||||||
let skip_type = item.get("skipType").and_then(Value::as_str)?;
|
.iter()
|
||||||
let interval = item.get("interval")?;
|
.filter_map(|item| {
|
||||||
let start = interval.get("startTime").and_then(Value::as_f64)?;
|
let skip_type = item.get("skipType").and_then(Value::as_str)?;
|
||||||
let end = interval.get("endTime").and_then(Value::as_f64)?;
|
let interval = item.get("interval")?;
|
||||||
let start_ms = normalize_time(start);
|
let start = interval.get("startTime").and_then(Value::as_f64)?;
|
||||||
let end_ms = normalize_time(end);
|
let end = interval.get("endTime").and_then(Value::as_f64)?;
|
||||||
if end_ms <= start_ms { return None; }
|
let start_ms = normalize_time(start);
|
||||||
Some(make_segment(normalize_skip_type(skip_type), start_ms, end_ms))
|
let end_ms = normalize_time(end);
|
||||||
}).collect();
|
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()
|
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 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();
|
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> {
|
pub(crate) fn merge_intro_segments_json(sources_json: &str) -> Option<String> {
|
||||||
let sources: Vec<Value> = serde_json::from_str(sources_json).ok()?;
|
let sources: Vec<Value> = serde_json::from_str(sources_json).ok()?;
|
||||||
let all: Vec<Value> = sources.into_iter().flat_map(|s| {
|
let all: Vec<Value> = sources
|
||||||
s.as_array().cloned().unwrap_or_default()
|
.into_iter()
|
||||||
}).collect();
|
.flat_map(|s| s.as_array().cloned().unwrap_or_default())
|
||||||
|
.collect();
|
||||||
dedup_and_sort(all)
|
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 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);
|
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() {
|
if seen.insert(key, true).is_none() {
|
||||||
result.push(seg);
|
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")]
|
#[cfg(feature = "uniffi-bindings")]
|
||||||
uniffi::setup_scaffolding!();
|
uniffi::setup_scaffolding!();
|
||||||
|
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod addon_protocol;
|
mod addon_protocol;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod addon_resource;
|
mod addon_resource;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod addon_store;
|
mod addon_store;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod app_state;
|
mod app_state;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod calendar_plan;
|
mod calendar_plan;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod cast_protocol;
|
mod cast_protocol;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod constants;
|
mod constants;
|
||||||
mod content_identity;
|
mod content_identity;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
pub mod core_api;
|
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;
|
pub mod core_contract;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod data_policy;
|
mod data_policy;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod discovery_plan;
|
mod discovery_plan;
|
||||||
#[cfg(feature = "native")]
|
#[cfg(feature = "native")]
|
||||||
mod dolby_vision_rpu;
|
mod dolby_vision_rpu;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod external_sync;
|
mod external_sync;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod headless_adapter_plan;
|
mod headless_adapter_plan;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod headless_engine;
|
mod headless_engine;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod home_ranking;
|
mod home_ranking;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod intro_segments;
|
mod intro_segments;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod library_state;
|
mod library_state;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod offline_download;
|
mod offline_download;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod platform_plan;
|
mod platform_plan;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod player_flow;
|
mod player_flow;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod player_policy;
|
mod player_policy;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod player_scrobble;
|
mod player_scrobble;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod profile_contract;
|
mod profile_contract;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod profile_prefs;
|
mod profile_prefs;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod repository_flow;
|
mod repository_flow;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod search_plan;
|
mod search_plan;
|
||||||
mod stream_policy;
|
mod stream_policy;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod tmdb_plan;
|
mod tmdb_plan;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
mod watchlist_plan;
|
mod watchlist_plan;
|
||||||
|
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
pub mod env;
|
pub mod env;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
pub mod ffi;
|
pub mod ffi;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|
||||||
|
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
|
||||||
pub mod bindings;
|
pub mod bindings;
|
||||||
|
|
||||||
pub use core_api::FluxaCore;
|
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
|
// pub(crate) for real consumers — this exists purely so libFuzzer can call
|
||||||
// straight into them without going through ffi::core_invoke's catch_unwind,
|
// straight into them without going through ffi::core_invoke's catch_unwind,
|
||||||
// which would otherwise swallow the exact panics fuzzing is trying to find.
|
// 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 mod fuzz_targets {
|
||||||
pub use crate::addon_protocol::parse_manifest;
|
pub use crate::addon_protocol::parse_manifest;
|
||||||
pub use crate::content_identity::{
|
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 {
|
mod tests {
|
||||||
use crate::addon_protocol::{
|
use crate::addon_protocol::{
|
||||||
catalog_has_required_extra_except, catalog_requires_extra, catalog_supports_extra,
|
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())
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
.expect("torrent fallback info");
|
.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!(
|
assert_eq!(
|
||||||
fallback.get("selectedReason").and_then(Value::as_str),
|
fallback.get("selectedReason").and_then(Value::as_str),
|
||||||
Some("largest-video")
|
Some("largest-video")
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,10 @@ pub(crate) fn filter_home_continue_watching_json(
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|item| {
|
.filter(|item| {
|
||||||
let item_type = item.get("type").and_then(Value::as_str).unwrap_or("");
|
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 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 duration = item.get("duration").and_then(Value::as_i64).unwrap_or(0);
|
||||||
let is_series = matches!(item_type, "series" | "tv" | "anime");
|
let is_series = matches!(item_type, "series" | "tv" | "anime");
|
||||||
|
|
@ -185,10 +188,11 @@ pub(crate) fn filter_home_continue_watching_json(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let watched_keys = crate::content_identity::content_watched_keys_value(item);
|
let watched_keys = crate::content_identity::content_watched_keys_value(item);
|
||||||
if item_type == "movie" && !movie_keys.is_empty() {
|
if item_type == "movie"
|
||||||
if watched_keys.iter().any(|k| movie_keys.contains(k.as_str())) {
|
&& !movie_keys.is_empty()
|
||||||
return false;
|
&& 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 is_series && !episode_keys.is_empty() && !last_video_id.is_empty() {
|
||||||
if let Some((_, season, episode)) =
|
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) {
|
if !lib.get("history").map(Value::is_array).unwrap_or(false) {
|
||||||
lib.insert("history".to_string(), json!([]));
|
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!([]));
|
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!({}));
|
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!({}));
|
lib.insert("watched".to_string(), json!({}));
|
||||||
}
|
}
|
||||||
if !lib.get("dropped").map(Value::is_array).unwrap_or(false) {
|
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 {
|
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);
|
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;
|
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> {
|
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),
|
"lastStreamUrl": entry.get("lastStreamUrl").cloned().unwrap_or(Value::Null),
|
||||||
"lastStreamTitle": entry.get("lastStreamTitle").cloned().unwrap_or(Value::Null),
|
"lastStreamTitle": entry.get("lastStreamTitle").cloned().unwrap_or(Value::Null),
|
||||||
"lastStream": entry.get("lastStream").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),
|
"savedAt": entry.get("savedAt").cloned().unwrap_or(Value::Null),
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
@ -316,11 +339,17 @@ pub(crate) fn compute_continue_watching_badges_json(
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let mut by_id: std::collections::HashMap<String, Value> = {
|
let mut by_id: std::collections::HashMap<String, Value> = {
|
||||||
let items: Vec<Value> = serde_json::from_str(candidates_json).unwrap_or_default();
|
let items: Vec<Value> = serde_json::from_str(candidates_json).unwrap_or_default();
|
||||||
items.into_iter().filter_map(|item| {
|
items
|
||||||
let id = item.get("id").or_else(|| item.get("_id"))
|
.into_iter()
|
||||||
.and_then(Value::as_str).map(str::to_string)?;
|
.filter_map(|item| {
|
||||||
Some((id, item))
|
let id = item
|
||||||
}).collect()
|
.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> =
|
let videos_by_series: serde_json::Map<String, Value> =
|
||||||
serde_json::from_str(videos_by_series_json).unwrap_or_default();
|
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();
|
let mut finished_series: Vec<String> = Vec::new();
|
||||||
for (series_id, candidate) in by_id.iter_mut() {
|
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) {
|
let next =
|
||||||
NextEpisodeOutcome::Skip => continue,
|
match next_episode_for_candidate(series_id, candidate, &videos_by_series, &cw_list_ids)
|
||||||
NextEpisodeOutcome::MarkFinished => {
|
{
|
||||||
finished_series.push(series_id.clone());
|
NextEpisodeOutcome::Skip => continue,
|
||||||
continue;
|
NextEpisodeOutcome::MarkFinished => {
|
||||||
}
|
finished_series.push(series_id.clone());
|
||||||
NextEpisodeOutcome::Found(next) => next,
|
continue;
|
||||||
};
|
}
|
||||||
|
NextEpisodeOutcome::Found(next) => next,
|
||||||
|
};
|
||||||
apply_next_episode_badge(series_id, candidate, &next, now_ms);
|
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();
|
let mut result: Vec<Value> = by_id.into_values().collect();
|
||||||
result.sort_by(|a, b| {
|
result.sort_by(|a, b| {
|
||||||
let a_new = a.get("continueWatchingBadge").and_then(Value::as_str) == Some("newEpisode");
|
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");
|
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 }; }
|
if a_new != b_new {
|
||||||
let a_time = a.get("savedAt").or_else(|| a.get("newEpisodeReleasedAt")).and_then(Value::as_str).unwrap_or("");
|
return if a_new {
|
||||||
let b_time = b.get("savedAt").or_else(|| b.get("newEpisodeReleasedAt")).and_then(Value::as_str).unwrap_or("");
|
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)
|
b_time.cmp(a_time)
|
||||||
});
|
});
|
||||||
serde_json::to_string(&result).ok()
|
serde_json::to_string(&result).ok()
|
||||||
|
|
@ -368,7 +415,10 @@ fn seed_candidates_from_last_watched(
|
||||||
last_watched: &serde_json::Map<String, Value>,
|
last_watched: &serde_json::Map<String, Value>,
|
||||||
) {
|
) {
|
||||||
for (series_id, raw) in last_watched {
|
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;
|
let record = raw;
|
||||||
by_id.entry(series_id.clone()).or_insert_with(|| json!({
|
by_id.entry(series_id.clone()).or_insert_with(|| json!({
|
||||||
"id": series_id,
|
"id": series_id,
|
||||||
|
|
@ -427,16 +477,38 @@ fn next_episode_for_candidate(
|
||||||
NextEpisodeOutcome::Skip
|
NextEpisodeOutcome::Skip
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
let stored_badge = candidate.get("continueWatchingBadge").and_then(Value::as_str);
|
let stored_badge = candidate
|
||||||
let stored_video_id = candidate.get("lastVideoId").and_then(Value::as_str).unwrap_or("").to_string();
|
.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
|
// When the stored badge is scheduledEpisode, lastEpisodeNumber already points to the
|
||||||
// scheduled episode itself. Re-check that same episode rather than advancing past it.
|
// scheduled episode itself. Re-check that same episode rather than advancing past it.
|
||||||
let next = if stored_badge == Some("scheduledEpisode") {
|
let next = if stored_badge == Some("scheduledEpisode") {
|
||||||
videos.iter().find(|v| {
|
videos
|
||||||
let vid = v.get("id").or_else(|| v.get("_id")).and_then(Value::as_str).unwrap_or("");
|
.iter()
|
||||||
vid == stored_video_id
|
.find(|v| {
|
||||||
}).cloned().or_else(|| first_episode_after(videos, season, episode))
|
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 {
|
} else {
|
||||||
first_episode_after(videos, season, episode)
|
first_episode_after(videos, season, episode)
|
||||||
};
|
};
|
||||||
|
|
@ -452,13 +524,30 @@ fn next_episode_for_candidate(
|
||||||
// Computes the badge (upNext / newEpisode / scheduledEpisode) for advancing `candidate`
|
// Computes the badge (upNext / newEpisode / scheduledEpisode) for advancing `candidate`
|
||||||
// to `next`, and rewrites `candidate` in place to point at that episode.
|
// 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) {
|
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 existing_video_id = candidate
|
||||||
let next_id = next.get("id").or_else(|| next.get("_id")).and_then(Value::as_str)
|
.get("lastVideoId")
|
||||||
.unwrap_or(&existing_video_id).to_string();
|
.and_then(Value::as_str)
|
||||||
if !is_up_next_item(candidate) && existing_video_id != next_id { return; }
|
.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_new_target = existing_video_id != next_id;
|
||||||
let is_released = is_episode_released(next, now_ms);
|
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 {
|
let badge = if !is_released {
|
||||||
"scheduledEpisode"
|
"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() {
|
} else if let Some(b) = existing_badge.as_deref() {
|
||||||
b
|
b
|
||||||
} else {
|
} 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())
|
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||||
.map(|dt| dt.timestamp_millis()).unwrap_or(now_ms);
|
.map(|dt| dt.timestamp_millis())
|
||||||
let next_released_at = next.get("released").and_then(Value::as_str)
|
.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())
|
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||||
.map(|dt| dt.timestamp_millis()).unwrap_or(0);
|
.map(|dt| dt.timestamp_millis())
|
||||||
let was_released_when_watched = next.get("released").is_none() || next_released_at <= watched_at;
|
.unwrap_or(0);
|
||||||
if was_released_when_watched { "upNext" } else { "newEpisode" }
|
let was_released_when_watched =
|
||||||
}.to_string();
|
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)
|
.map(str::to_string)
|
||||||
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
|
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
|
||||||
let saved_at_new = if is_new_target && badge == "newEpisode" {
|
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> {
|
fn first_episode_after(videos: &[Value], season: i64, episode: i64) -> Option<Value> {
|
||||||
let mut candidates: Vec<&Value> = videos.iter().filter(|v| {
|
let mut candidates: Vec<&Value> = videos
|
||||||
let vs = v.get("season").and_then(Value::as_i64).unwrap_or(0);
|
.iter()
|
||||||
let ve = v.get("episode").or_else(|| v.get("number")).and_then(Value::as_i64).unwrap_or(0);
|
.filter(|v| {
|
||||||
vs > season || (vs == season && ve > episode)
|
let vs = v.get("season").and_then(Value::as_i64).unwrap_or(0);
|
||||||
}).collect();
|
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| {
|
candidates.sort_by(|a, b| {
|
||||||
let as_ = a.get("season").and_then(Value::as_i64).unwrap_or(0);
|
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);
|
let bs = b.get("season").and_then(Value::as_i64).unwrap_or(0);
|
||||||
if as_ != bs { return as_.cmp(&bs); }
|
if as_ != bs {
|
||||||
let ae = a.get("episode").or_else(|| a.get("number")).and_then(Value::as_i64).unwrap_or(0);
|
return as_.cmp(&bs);
|
||||||
let be = b.get("episode").or_else(|| b.get("number")).and_then(Value::as_i64).unwrap_or(0);
|
}
|
||||||
|
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)
|
ae.cmp(&be)
|
||||||
});
|
});
|
||||||
candidates.first().map(|v| (*v).clone())
|
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`.
|
/// Given a library JSON and a set of just-watched video IDs, update `lastWatchedEpisodes`.
|
||||||
/// Returns the updated library as JSON.
|
/// 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 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)
|
let watched_ids: std::collections::HashSet<String> = serde_json::from_str(watched_ids_json)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v: Value| v.as_array().map(|arr| {
|
.and_then(|v: Value| {
|
||||||
arr.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()
|
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();
|
.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 {
|
for (series_id, raw) in &progress {
|
||||||
let video_id = raw.get("lastVideoId").and_then(Value::as_str).unwrap_or("");
|
let video_id = raw.get("lastVideoId").and_then(Value::as_str).unwrap_or("");
|
||||||
if video_id.is_empty() || !watched_ids.contains(video_id) { continue; }
|
if video_id.is_empty() || !watched_ids.contains(video_id) {
|
||||||
let meta = match raw.get("meta") { Some(m) if m.get("type").and_then(Value::as_str) == Some("series") => m, _ => continue };
|
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!({
|
last_watched.insert(series_id.clone(), json!({
|
||||||
"meta": meta,
|
"meta": meta,
|
||||||
"lastVideoId": video_id,
|
"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() {
|
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())
|
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>(),
|
parts[parts.len() - 1].parse::<i64>(),
|
||||||
) {
|
) {
|
||||||
if s > 0 && e > 0 {
|
if s > 0 && e > 0 {
|
||||||
if season.is_none() { season = Some(s); }
|
if season.is_none() {
|
||||||
if episode.is_none() { episode = Some(e); }
|
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 cw_background = str_field("continueWatchingBackground");
|
||||||
|
|
||||||
let is_real_backdrop = background.as_deref().is_some_and(|bg| {
|
let is_real_backdrop = background.as_deref().is_some_and(|bg| {
|
||||||
poster.as_deref().map_or(true, |p| bg != p)
|
(poster.as_deref() != Some(bg)) && !bg.to_lowercase().contains("/poster/")
|
||||||
&& !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 {
|
let result = if !is_horizontal {
|
||||||
thumbnail
|
thumbnail
|
||||||
|
|
@ -701,8 +851,16 @@ pub(crate) fn continue_watching_card_fields_json(
|
||||||
let fields: Vec<Value> = items
|
let fields: Vec<Value> = items
|
||||||
.iter()
|
.iter()
|
||||||
.map(|item| {
|
.map(|item| {
|
||||||
let id = item.get("id").and_then(Value::as_str).unwrap_or("").to_string();
|
let id = item
|
||||||
let artwork = select_continue_watching_artwork_json(&item.to_string(), artwork_preference, is_horizontal);
|
.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(
|
let episode_line = format_episode_line_json(
|
||||||
item.get("lastEpisodeName").and_then(Value::as_str),
|
item.get("lastEpisodeName").and_then(Value::as_str),
|
||||||
item.get("lastEpisodeSeason").and_then(Value::as_i64),
|
item.get("lastEpisodeSeason").and_then(Value::as_i64),
|
||||||
|
|
@ -792,9 +950,54 @@ mod tests {
|
||||||
.expect("badges");
|
.expect("badges");
|
||||||
let result = result.as_array().unwrap();
|
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]["id"], "s1");
|
||||||
assert_eq!(result[0]["lastVideoId"], "s1:1:3");
|
assert_eq!(result[0]["lastVideoId"], "s1:1:3");
|
||||||
assert_eq!(result[0]["continueWatchingBadge"], "upNext");
|
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" => {
|
"search" => {
|
||||||
let query = request.query.as_deref().unwrap_or("");
|
let query = request.query.as_deref().unwrap_or("");
|
||||||
for addon in &request.addons {
|
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) {
|
for catalog in addon_catalogs(addon) {
|
||||||
if !catalog_supports_search(&catalog) {
|
if !catalog_supports_search(&catalog) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(content_type) = catalog.get("type").and_then(Value::as_str) else { continue };
|
let Some(content_type) = catalog.get("type").and_then(Value::as_str) else {
|
||||||
let Some(id) = catalog.get("id").and_then(Value::as_str) else { continue };
|
continue;
|
||||||
|
};
|
||||||
|
let Some(id) = catalog.get("id").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
requests.push(json!({
|
requests.push(json!({
|
||||||
"url": build_resource_url(transport_url, "catalog", content_type, id, Some(&json!({"search": query}).to_string())),
|
"url": build_resource_url(transport_url, "catalog", content_type, id, Some(&json!({"search": query}).to_string())),
|
||||||
"kind": "search",
|
"kind": "search",
|
||||||
|
|
@ -143,9 +149,12 @@ pub(crate) fn resource_fetch_plan_json(request_json: &str) -> Option<String> {
|
||||||
}
|
}
|
||||||
"discover" => {
|
"discover" => {
|
||||||
let genre = request.genre.as_deref();
|
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());
|
let extra = genre.map(|value| json!({"genre": value}).to_string());
|
||||||
requests.push(json!({
|
requests.push(json!({
|
||||||
"url": build_resource_url(
|
"url": build_resource_url(
|
||||||
&catalog.transport_url,
|
&catalog.transport_url,
|
||||||
"catalog",
|
"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)) {
|
if !addon_supports(addon, "meta", content_type, Some(id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(transport_url) = addon_transport_url(addon) else { continue };
|
let Some(transport_url) = addon_transport_url(addon) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
requests.push(json!({
|
requests.push(json!({
|
||||||
"url": build_resource_url(transport_url, "meta", content_type, id, None),
|
"url": build_resource_url(transport_url, "meta", content_type, id, None),
|
||||||
"kind": "metaDetail",
|
"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) {
|
if !addon_supports(addon, "stream", content_type, None) {
|
||||||
continue;
|
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 {
|
for id in &request.request_ids {
|
||||||
requests.push(json!({
|
requests.push(json!({
|
||||||
"url": build_resource_url(transport_url, "stream", content_type, id, None),
|
"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)) {
|
if !addon_supports(addon, "meta", "series", Some(series_id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(transport_url) = addon_transport_url(addon) else { continue };
|
let Some(transport_url) = addon_transport_url(addon) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
requests.push(json!({
|
requests.push(json!({
|
||||||
"url": build_resource_url(transport_url, "meta", "series", series_id, None),
|
"url": build_resource_url(transport_url, "meta", "series", series_id, None),
|
||||||
"kind": "seasonEpisodes",
|
"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)) {
|
if !addon_supports(addon, "subtitles", content_type, Some(id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(transport_url) = addon_transport_url(addon) else { continue };
|
let Some(transport_url) = addon_transport_url(addon) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
requests.push(json!({
|
requests.push(json!({
|
||||||
"url": build_resource_url(transport_url, "subtitles", content_type, id, None),
|
"url": build_resource_url(transport_url, "subtitles", content_type, id, None),
|
||||||
"kind": "subtitles",
|
"kind": "subtitles",
|
||||||
|
|
@ -308,14 +325,16 @@ pub(crate) fn playback_prepare_plan_json(request_json: &str) -> Option<String> {
|
||||||
.and_then(Value::as_bool)
|
.and_then(Value::as_bool)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
|| playable_url.starts_with("stremio://torrent/")
|
|| 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
|
let compatible = info
|
||||||
.get("isLikelyPlayerCompatible")
|
.get("isLikelyPlayerCompatible")
|
||||||
.and_then(Value::as_bool)
|
.and_then(Value::as_bool)
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
let mode = if playable_url.is_empty() {
|
let mode = if playable_url.is_empty() || !compatible {
|
||||||
"reject"
|
|
||||||
} else if !compatible {
|
|
||||||
"reject"
|
"reject"
|
||||||
} else if is_torrent {
|
} else if is_torrent {
|
||||||
"torrent"
|
"torrent"
|
||||||
|
|
@ -439,26 +458,29 @@ pub(crate) fn detail_episode_plan_json(request_json: &str) -> Option<String> {
|
||||||
seasons.dedup();
|
seasons.dedup();
|
||||||
// Search for the target episode across ALL episodes before season filtering,
|
// 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.
|
// so that a lastVideoId from a later season (e.g. S9 when default would be S1) is found.
|
||||||
let target_episode = request
|
let target_episode = request.selected_episode_id.as_deref().and_then(|id| {
|
||||||
.selected_episode_id
|
request
|
||||||
.as_deref()
|
.episodes
|
||||||
.and_then(|id| {
|
.iter()
|
||||||
request
|
.find(|ep| ep.get("id").and_then(Value::as_str) == Some(id))
|
||||||
.episodes
|
.cloned()
|
||||||
.iter()
|
});
|
||||||
.find(|ep| ep.get("id").and_then(Value::as_str) == Some(id))
|
|
||||||
.cloned()
|
|
||||||
});
|
|
||||||
let selected_season = target_episode
|
let selected_season = target_episode
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|ep| ep.get("season").and_then(Value::as_i64))
|
.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())
|
.or_else(|| seasons.first().copied())
|
||||||
.unwrap_or(1);
|
.unwrap_or(1);
|
||||||
let episodes = request
|
let episodes = request
|
||||||
.episodes
|
.episodes
|
||||||
.into_iter()
|
.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<_>>();
|
.collect::<Vec<_>>();
|
||||||
let selected_episode = target_episode
|
let selected_episode = target_episode
|
||||||
.filter(|ep| ep.get("season").and_then(Value::as_i64).unwrap_or(1) == selected_season)
|
.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 {
|
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> {
|
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 {
|
fn addon_display_name(addon: &Value) -> String {
|
||||||
addon.get("name")
|
addon
|
||||||
.or_else(|| addon.get("manifest").and_then(|manifest| manifest.get("name")))
|
.get("name")
|
||||||
|
.or_else(|| {
|
||||||
|
addon
|
||||||
|
.get("manifest")
|
||||||
|
.and_then(|manifest| manifest.get("name"))
|
||||||
|
})
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("Unknown Addon")
|
.unwrap_or("Unknown Addon")
|
||||||
.to_string()
|
.to_string()
|
||||||
|
|
@ -558,10 +588,16 @@ struct DiscoverCatalog {
|
||||||
fn discover_catalog_options(addons: &[Value], selected_type: &str) -> Vec<DiscoverCatalog> {
|
fn discover_catalog_options(addons: &[Value], selected_type: &str) -> Vec<DiscoverCatalog> {
|
||||||
let mut options = Vec::new();
|
let mut options = Vec::new();
|
||||||
for addon in addons {
|
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) {
|
for catalog in addon_catalogs(addon) {
|
||||||
let Some(content_type) = catalog.get("type").and_then(Value::as_str) else { continue };
|
let Some(content_type) = catalog.get("type").and_then(Value::as_str) else {
|
||||||
let Some(id) = catalog.get("id").and_then(Value::as_str) else { continue };
|
continue;
|
||||||
|
};
|
||||||
|
let Some(id) = catalog.get("id").and_then(Value::as_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
if !selected_type.is_empty() && content_type != selected_type {
|
if !selected_type.is_empty() && content_type != selected_type {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -583,7 +619,9 @@ fn playback_title(meta: Option<&Value>, episode: Option<&Value>, stream: &Value)
|
||||||
.or_else(|| stream.get("name"))
|
.or_else(|| stream.get("name"))
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("Fluxa");
|
.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
|
let episode_number = episode
|
||||||
.and_then(|value| value.get("episode").or_else(|| value.get("number")))
|
.and_then(|value| value.get("episode").or_else(|| value.get("number")))
|
||||||
.and_then(Value::as_i64);
|
.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) {
|
let episode_line = match (season, episode_number) {
|
||||||
(Some(season), Some(number)) => {
|
(Some(season), Some(number)) => {
|
||||||
let prefix = format!("S{season}:E{number}");
|
let prefix = format!("S{season}:E{number}");
|
||||||
Some(match episode_name.filter(|value| !value.trim().is_empty()) {
|
Some(
|
||||||
Some(name) => format!("{prefix} {}", name.trim()),
|
match episode_name.filter(|value| !value.trim().is_empty()) {
|
||||||
None => prefix,
|
Some(name) => format!("{prefix} {}", name.trim()),
|
||||||
})
|
None => prefix,
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => 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 {
|
fn playback_artwork(meta: Option<&Value>, episode: Option<&Value>) -> Value {
|
||||||
let background = meta
|
let background = meta
|
||||||
.and_then(|value| first_text(value, &["background", "backgroundUrl", "backdrop", "backdropUrl"]))
|
.and_then(|value| {
|
||||||
.or_else(|| episode.and_then(|value| value.get("thumbnail")).and_then(Value::as_str))
|
first_text(
|
||||||
.or_else(|| meta.and_then(|value| value.get("poster")).and_then(Value::as_str));
|
value,
|
||||||
let logo = meta.and_then(|value| first_text(value, &["logo", "logoUrl", "titleLogo", "titleLogoUrl"]));
|
&["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 })
|
json!({ "background": background, "logo": logo })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn first_text<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> {
|
fn first_text<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> {
|
||||||
keys.iter()
|
keys.iter().find_map(|key| {
|
||||||
.find_map(|key| value.get(*key).and_then(Value::as_str).filter(|text| !text.trim().is_empty()))
|
value
|
||||||
|
.get(*key)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|text| !text.trim().is_empty())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_preference_value(key: &str, value: Value) -> Value {
|
fn normalize_preference_value(key: &str, value: Value) -> Value {
|
||||||
|
|
@ -629,7 +686,9 @@ fn normalize_preference_value(key: &str, value: Value) -> Value {
|
||||||
"preferred",
|
"preferred",
|
||||||
),
|
),
|
||||||
"torrentSpeedPreset" => enum_string(value, &["default", "fast", "ultra_fast"], "default"),
|
"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"),
|
"subtitleSize" => enum_string(value, &["50", "75", "100", "125", "150", "200"], "100"),
|
||||||
_ => value,
|
_ => value,
|
||||||
}
|
}
|
||||||
|
|
@ -645,9 +704,14 @@ fn enum_string(value: Value, allowed: &[&str], fallback: &str) -> Value {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn addon_key(addon: &Value) -> String {
|
fn addon_key(addon: &Value) -> String {
|
||||||
addon.get("transportUrl")
|
addon
|
||||||
|
.get("transportUrl")
|
||||||
.or_else(|| addon.get("id"))
|
.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)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string()
|
.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.
|
/// 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.
|
/// `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
|
let explicit = item_resource
|
||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.or_else(|| request_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.len(), 1);
|
||||||
assert_eq!(requests[0]["kind"], "catalogPage");
|
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]
|
#[test]
|
||||||
|
|
@ -773,9 +844,16 @@ mod tests {
|
||||||
.expect("plan");
|
.expect("plan");
|
||||||
let requests = plan["requests"].as_array().unwrap();
|
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]["catalogId"], "top");
|
||||||
assert_eq!(requests[0]["categoryName"], "Addon One - Top Movies");
|
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()
|
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 {
|
match action {
|
||||||
PlayerFlowAction::LoadStreamsRequested {
|
PlayerFlowAction::LoadStreamsRequested {
|
||||||
content_type,
|
content_type,
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,10 @@ pub(crate) fn player_backend_selection_json(request_json: &str) -> Option<String
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
let is_dv_stream = stream.get("dv").and_then(Value::as_bool).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 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)
|
let needs_mpv_for_hdr = (is_dv_stream && !request.device_has_dolby_vision_decoder)
|
||||||
|| (is_hdr_stream && !request.device_has_hdr_display);
|
|| (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) {
|
if rejected == Some(id) {
|
||||||
return None;
|
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));
|
let is_video = video_exts.iter().any(|ext| path.ends_with(ext));
|
||||||
if !is_video {
|
if !is_video {
|
||||||
return None;
|
return None;
|
||||||
|
|
@ -212,17 +219,9 @@ pub(crate) fn player_buffer_targets_json(request_json: &str) -> Option<String> {
|
||||||
_ => 1.0,
|
_ => 1.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let base_forward_ms = request
|
let base_forward_ms =
|
||||||
.forward_buffer_seconds
|
request.forward_buffer_seconds.unwrap_or(120).clamp(10, 600) as f64 * 1000.0 * data_factor;
|
||||||
.unwrap_or(120)
|
let base_back_ms = request.back_buffer_seconds.unwrap_or(30).clamp(5, 120) as f64 * 1000.0;
|
||||||
.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
|
// Torrent streams need smaller buffers to avoid filling the local proxy
|
||||||
let (forward_ms, back_ms) = if request.is_torrent {
|
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", &[]);
|
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();
|
let url_lower = req.url.to_lowercase();
|
||||||
if url_lower.ends_with(".m3u8")
|
let is_hls = url_lower.ends_with(".m3u8") || url_lower.contains(".m3u8?");
|
||||||
|| url_lower.contains(".m3u8?")
|
let is_dash = url_lower.ends_with(".mpd") || url_lower.contains(".mpd?");
|
||||||
|| url_lower.ends_with(".mpd")
|
|
||||||
|| url_lower.contains(".mpd?")
|
|
||||||
{
|
|
||||||
return plan_rich("none", "manifest_handled", "unknown", "none", "high", &[]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !is_dolby_vision_stream(&req.stream, &req.url) {
|
if !is_dolby_vision_stream(&req.stream, &req.url) {
|
||||||
return plan_rich("none", "not_dv", "unknown", "none", "high", &[]);
|
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
|
let native_passthrough = req.device_has_dv_decoder
|
||||||
&& (req.device_has_dv_display || req.fallback_mode != "convert_dv81");
|
&& (req.device_has_dv_display || req.fallback_mode != "convert_dv81");
|
||||||
if native_passthrough {
|
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 profile = detect_dv_profile(&req.stream);
|
||||||
let container = detect_container(&req.url);
|
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
|
// Hard safety gates: profiles with no HDR base layer cannot be safely
|
||||||
// rewritten — stripping DVCC would expose a DV-only bitstream to an
|
// rewritten — stripping DVCC would expose a DV-only bitstream to an
|
||||||
// HDR10 decoder, producing corrupted colour.
|
// HDR10 decoder, producing corrupted colour.
|
||||||
|
|
@ -525,14 +529,26 @@ pub(crate) fn dv_proxy_plan_json(request_json: &str) -> Option<String> {
|
||||||
"HDR10",
|
"HDR10",
|
||||||
"medium",
|
"medium",
|
||||||
"rpu_convert_rejected_not_annexb",
|
"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",
|
"dvcc_strip",
|
||||||
"HDR10",
|
"HDR10",
|
||||||
"medium",
|
"medium",
|
||||||
"p7_dvcc_strip_hdr10_base",
|
"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 => (
|
DvProfile::P8Hdr10 => (
|
||||||
|
|
@ -540,39 +556,70 @@ pub(crate) fn dv_proxy_plan_json(request_json: &str) -> Option<String> {
|
||||||
"HDR10",
|
"HDR10",
|
||||||
"low",
|
"low",
|
||||||
"p8_1_hdr10_compat_base",
|
"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 => (
|
DvProfile::P8Hlg => (
|
||||||
"dvcc_strip",
|
"dvcc_strip",
|
||||||
"HLG",
|
"HLG",
|
||||||
"medium",
|
"medium",
|
||||||
"p8_4_hlg_compat_base",
|
"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 => (
|
DvProfile::P8Unknown => (
|
||||||
"dvcc_strip",
|
"dvcc_strip",
|
||||||
"HDR10_assumed",
|
"HDR10_assumed",
|
||||||
"medium",
|
"medium",
|
||||||
"p8_compat_id_unknown_hdr10_assumed",
|
"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 => (
|
DvProfile::P10Hdr10 => (
|
||||||
"dvcc_strip",
|
"dvcc_strip",
|
||||||
"HDR10",
|
"HDR10",
|
||||||
"medium",
|
"medium",
|
||||||
"p10_compat_id_1_hdr10_base",
|
"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",
|
"dvcc_strip",
|
||||||
"HDR10_assumed",
|
"HDR10_assumed",
|
||||||
"medium",
|
"medium",
|
||||||
"unknown_profile_dvcc_strip_fallback",
|
"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(
|
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").
|
// 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 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
|
let filename = stream
|
||||||
.get("effectiveFilename")
|
.get("effectiveFilename")
|
||||||
.or_else(|| stream.get("filename"))
|
.or_else(|| stream.get("filename"))
|
||||||
|
|
@ -660,7 +710,8 @@ fn parse_dv_codec_string(text: &str) -> Option<DvProfile> {
|
||||||
let mut parts = after.splitn(3, '.');
|
let mut parts = after.splitn(3, '.');
|
||||||
// Take only the leading digits from each field (e.g. "08" from "08.01 Remux").
|
// 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 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(leading_digits)
|
||||||
.and_then(|s| s.parse().ok());
|
.and_then(|s| s.parse().ok());
|
||||||
return Some(profile_from_nums(profile, compat));
|
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> {
|
fn leading_digits(s: &str) -> Option<&str> {
|
||||||
let end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
|
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.
|
/// 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)] = &[
|
let patterns: &[(&str, DvProfile)] = &[
|
||||||
("P8.1", DvProfile::P8Hdr10),
|
("P8.1", DvProfile::P8Hdr10),
|
||||||
("P8.4", DvProfile::P8Hlg),
|
("P8.4", DvProfile::P8Hlg),
|
||||||
("P7", DvProfile::P7),
|
("P7", DvProfile::P7),
|
||||||
("P8", DvProfile::P8Unknown),
|
("P8", DvProfile::P8Unknown),
|
||||||
("P10", DvProfile::P10Other),
|
("P10", DvProfile::P10Other),
|
||||||
("P5", DvProfile::P5),
|
("P5", DvProfile::P5),
|
||||||
("P4", DvProfile::P4),
|
("P4", DvProfile::P4),
|
||||||
];
|
];
|
||||||
for (pat, profile) in patterns {
|
for (pat, profile) in patterns {
|
||||||
if contains_word(text, pat) {
|
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.
|
/// Returns true when the stream or URL is identifiable as Dolby Vision content.
|
||||||
fn is_dolby_vision_stream(stream: &Value, url: &str) -> bool {
|
fn is_dolby_vision_stream(stream: &Value, url: &str) -> bool {
|
||||||
if stream.get("dv").and_then(Value::as_bool).unwrap_or(false)
|
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()
|
|| stream.get("dvProfile").and_then(Value::as_i64).is_some()
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let name = stream.get("name").and_then(Value::as_str).unwrap_or("");
|
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
|
let filename = stream
|
||||||
.get("effectiveFilename")
|
.get("effectiveFilename")
|
||||||
.or_else(|| stream.get("filename"))
|
.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 {
|
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 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 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
|
let mode = prefs
|
||||||
.get("streamSourceSelectionMode")
|
.get("streamSourceSelectionMode")
|
||||||
.and_then(Value::as_str)
|
.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")
|
.get("behaviorHints")
|
||||||
.and_then(|h| h.get("bingeGroup"))
|
.and_then(|h| h.get("bingeGroup"))
|
||||||
.and_then(Value::as_str)
|
.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"
|
(try_binge && has_binge_group) || mode != "manual"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -829,13 +893,24 @@ pub(crate) fn select_next_episode_stream_json(
|
||||||
prefs_json: &str,
|
prefs_json: &str,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let streams: Vec<Value> = serde_json::from_str(streams_json).ok()?;
|
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 current: Value = serde_json::from_str(current_stream_json).ok()?;
|
||||||
let prefs: Value = serde_json::from_str(prefs_json).unwrap_or(Value::Null);
|
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 try_binge = prefs
|
||||||
let mode = prefs.get("streamSourceSelectionMode").and_then(Value::as_str).unwrap_or("manual");
|
.get("tryBingeGroup")
|
||||||
let regex_pat = prefs.get("streamSourceRegexPattern").and_then(Value::as_str).unwrap_or("");
|
.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
|
let cur_binge = current
|
||||||
.get("behaviorHints")
|
.get("behaviorHints")
|
||||||
.and_then(|h| h.get("bingeGroup"))
|
.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 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 {
|
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()
|
s.get("name"),
|
||||||
.flatten()
|
s.get("title"),
|
||||||
.filter_map(Value::as_str)
|
s.get("description"),
|
||||||
.collect::<Vec<_>>()
|
s.get("url"),
|
||||||
.join(" ")
|
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))) {
|
if let Some(matched) = streams.iter().find(|s| re.is_match(&stream_text(s))) {
|
||||||
return serde_json::to_string(matched).ok();
|
return serde_json::to_string(matched).ok();
|
||||||
|
|
@ -883,10 +968,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_selection_defaults_to_exoplayer() {
|
fn backend_selection_defaults_to_exoplayer() {
|
||||||
let result: Value = serde_json::from_str(
|
let result: Value = serde_json::from_str(
|
||||||
&player_backend_selection_json(
|
&player_backend_selection_json(r#"{"stream":{"url":"http://example.com/video.mp4"}}"#)
|
||||||
r#"{"stream":{"url":"http://example.com/video.mp4"}}"#,
|
.unwrap(),
|
||||||
)
|
|
||||||
.unwrap(),
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(result["backend"], "exoplayer");
|
assert_eq!(result["backend"], "exoplayer");
|
||||||
|
|
@ -940,7 +1023,10 @@ mod tests {
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
.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]
|
#[test]
|
||||||
|
|
@ -971,42 +1057,54 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_off_mode_returns_none() {
|
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["action"], "none");
|
||||||
assert_eq!(p["reason"], "user_disabled");
|
assert_eq!(p["reason"], "user_disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_hls_url_defers_to_manifest_rewrite() {
|
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["action"], "none");
|
||||||
assert_eq!(p["reason"], "manifest_handled");
|
assert_eq!(p["reason"], "manifest_handled");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_dash_url_defers_to_manifest_rewrite() {
|
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["action"], "none");
|
||||||
assert_eq!(p["reason"], "manifest_handled");
|
assert_eq!(p["reason"], "manifest_handled");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_non_dv_stream_returns_none() {
|
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["action"], "none");
|
||||||
assert_eq!(p["reason"], "not_dv");
|
assert_eq!(p["reason"], "not_dv");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_hw_dv_decoder_skips_proxy() {
|
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["action"], "none");
|
||||||
assert_eq!(p["reason"], "hw_dv_decoder");
|
assert_eq!(p["reason"], "hw_dv_decoder");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_p5_no_dv_decoder_returns_none() {
|
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["action"], "none");
|
||||||
assert_eq!(p["reason"], "no_hdr_base_layer");
|
assert_eq!(p["reason"], "no_hdr_base_layer");
|
||||||
assert_eq!(p["profile"], "P5");
|
assert_eq!(p["profile"], "P5");
|
||||||
|
|
@ -1014,7 +1112,9 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_p4_no_dv_decoder_returns_none() {
|
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["action"], "none");
|
||||||
assert_eq!(p["reason"], "no_hdr_base_layer");
|
assert_eq!(p["reason"], "no_hdr_base_layer");
|
||||||
assert_eq!(p["profile"], "P4");
|
assert_eq!(p["profile"], "P4");
|
||||||
|
|
@ -1022,28 +1122,36 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_p10_compat_0_returns_none() {
|
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["action"], "none");
|
||||||
assert_eq!(p["reason"], "p10_compat_id_no_hdr_base");
|
assert_eq!(p["reason"], "p10_compat_id_no_hdr_base");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_p10_compat_2_returns_none() {
|
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");
|
assert_eq!(p["action"], "none");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_unknown_profile_returns_none() {
|
fn dv_proxy_unknown_profile_returns_none() {
|
||||||
// DV detected but no profile info → safe default is 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["action"], "none");
|
||||||
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
|
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_p7_mkv_auto_gives_dvcc_strip_medium_safety() {
|
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["action"], "dvcc_strip");
|
||||||
assert_eq!(p["profile"], "P7");
|
assert_eq!(p["profile"], "P7");
|
||||||
assert_eq!(p["compatibility"], "HDR10");
|
assert_eq!(p["compatibility"], "HDR10");
|
||||||
|
|
@ -1052,7 +1160,9 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_p8_1_gives_dvcc_strip_low_safety() {
|
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["action"], "dvcc_strip");
|
||||||
assert_eq!(p["profile"], "P8.1");
|
assert_eq!(p["profile"], "P8.1");
|
||||||
assert_eq!(p["compatibility"], "HDR10");
|
assert_eq!(p["compatibility"], "HDR10");
|
||||||
|
|
@ -1061,7 +1171,9 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_p8_4_fallback_is_hlg_not_hdr10() {
|
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["action"], "dvcc_strip");
|
||||||
assert_eq!(p["profile"], "P8.4");
|
assert_eq!(p["profile"], "P8.4");
|
||||||
assert_eq!(p["compatibility"], "HLG");
|
assert_eq!(p["compatibility"], "HLG");
|
||||||
|
|
@ -1071,7 +1183,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_p8_unknown_compat_strips_with_assumed_hdr10() {
|
fn dv_proxy_p8_unknown_compat_strips_with_assumed_hdr10() {
|
||||||
// "DV P8" in name → P8Unknown → strip, medium safety, HDR10_assumed
|
// "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["action"], "dvcc_strip");
|
||||||
assert_eq!(p["profile"], "P8");
|
assert_eq!(p["profile"], "P8");
|
||||||
assert_eq!(p["compatibility"], "HDR10_assumed");
|
assert_eq!(p["compatibility"], "HDR10_assumed");
|
||||||
|
|
@ -1080,7 +1194,9 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_p10_compat_1_gives_dvcc_strip() {
|
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["action"], "dvcc_strip");
|
||||||
assert_eq!(p["profile"], "P10_compat1");
|
assert_eq!(p["profile"], "P10_compat1");
|
||||||
assert_eq!(p["compatibility"], "HDR10");
|
assert_eq!(p["compatibility"], "HDR10");
|
||||||
|
|
@ -1088,7 +1204,9 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_p7_raw_hevc_dv8_mode_gives_rpu_convert() {
|
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["action"], "rpu_convert");
|
||||||
assert_eq!(p["rpuMode"], 2);
|
assert_eq!(p["rpuMode"], 2);
|
||||||
assert_eq!(p["profile"], "P7");
|
assert_eq!(p["profile"], "P7");
|
||||||
|
|
@ -1096,7 +1214,9 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_p7_raw_hevc_auto_dv_display_gives_rpu_convert() {
|
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");
|
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
|
// 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
|
// rpu_convert needs a DV decoder in the convert_dv81 path, and dv8 mode
|
||||||
// is annexb-only (rejects non-raw-HEVC containers).
|
// 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["action"], "dvcc_strip");
|
||||||
assert_eq!(p["reason"], "rpu_convert_rejected_not_annexb");
|
assert_eq!(p["reason"], "rpu_convert_rejected_not_annexb");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_proxy_rpu_convert_rejected_for_mp4_falls_back_to_dvcc_strip() {
|
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["action"], "dvcc_strip");
|
||||||
assert_eq!(p["reason"], "rpu_convert_rejected_not_annexb");
|
assert_eq!(p["reason"], "rpu_convert_rejected_not_annexb");
|
||||||
}
|
}
|
||||||
|
|
@ -1120,7 +1244,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_detection_dolby_vision_p8_text_gives_action() {
|
fn dv_detection_dolby_vision_p8_text_gives_action() {
|
||||||
// "P8" token → P8Unknown → dvcc_strip
|
// "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_ne!(p["action"], "none");
|
||||||
assert_eq!(p["profile"], "P8");
|
assert_eq!(p["profile"], "P8");
|
||||||
}
|
}
|
||||||
|
|
@ -1128,7 +1254,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_detection_dovi_without_profile_gives_none() {
|
fn dv_detection_dovi_without_profile_gives_none() {
|
||||||
// DV detected ("dovi") but no profile info → unknown → 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["action"], "none");
|
||||||
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
|
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
|
||||||
}
|
}
|
||||||
|
|
@ -1136,21 +1264,27 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_detection_standalone_dv_without_profile_gives_none() {
|
fn dv_detection_standalone_dv_without_profile_gives_none() {
|
||||||
// "[DV]" detected but no profile info → 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["action"], "none");
|
||||||
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
|
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_detection_dvhe_fourcc_in_name_gives_profile_p7() {
|
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_ne!(p["action"], "none");
|
||||||
assert_eq!(p["profile"], "P7");
|
assert_eq!(p["profile"], "P7");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_detection_dvhe_08_01_in_name_gives_p8_1() {
|
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["action"], "dvcc_strip");
|
||||||
assert_eq!(p["profile"], "P8.1");
|
assert_eq!(p["profile"], "P8.1");
|
||||||
assert_eq!(p["safety"], "low");
|
assert_eq!(p["safety"], "low");
|
||||||
|
|
@ -1158,20 +1292,26 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_detection_no_false_positive_from_dvd() {
|
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["action"], "none");
|
||||||
assert_eq!(p["reason"], "not_dv");
|
assert_eq!(p["reason"], "not_dv");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_detection_no_false_positive_from_hdvd() {
|
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");
|
assert_eq!(p["action"], "none");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_detection_explicit_boolean_flag_with_profile() {
|
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_ne!(p["action"], "none");
|
||||||
assert_eq!(p["profile"], "P8.1");
|
assert_eq!(p["profile"], "P8.1");
|
||||||
}
|
}
|
||||||
|
|
@ -1179,14 +1319,18 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_detection_filename_without_profile_gives_none() {
|
fn dv_detection_filename_without_profile_gives_none() {
|
||||||
// DV keyword in filename but no profile → safe default is 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["action"], "none");
|
||||||
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
|
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dv_detection_dvhe_codec_in_filename_gives_profile() {
|
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_ne!(p["action"], "none");
|
||||||
assert_eq!(p["profile"], "P7");
|
assert_eq!(p["profile"], "P7");
|
||||||
}
|
}
|
||||||
|
|
@ -1197,7 +1341,8 @@ mod tests {
|
||||||
fn sample_p5_dvonly_no_fallback() {
|
fn sample_p5_dvonly_no_fallback() {
|
||||||
// P5 is HEVC single-layer with no HDR base. Stripping DVCC would expose
|
// 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.
|
// a DV-only bitstream to an HDR10 decoder → broken colour. Never rewrite.
|
||||||
let p = plan(r#"{
|
let p = plan(
|
||||||
|
r#"{
|
||||||
"stream": {
|
"stream": {
|
||||||
"name": "AETHER | 4K | Dolby Vision | DD+ Atmos",
|
"name": "AETHER | 4K | Dolby Vision | DD+ Atmos",
|
||||||
"description": "📺 4K | 🎬 dvhe.05.06 | 🔊 DD+ Atmos",
|
"description": "📺 4K | 🎬 dvhe.05.06 | 🔊 DD+ Atmos",
|
||||||
|
|
@ -1206,19 +1351,23 @@ mod tests {
|
||||||
"url": "https://debrid.example/movie.mkv",
|
"url": "https://debrid.example/movie.mkv",
|
||||||
"fallbackMode": "auto",
|
"fallbackMode": "auto",
|
||||||
"deviceHasDvDecoder": false
|
"deviceHasDvDecoder": false
|
||||||
}"#);
|
}"#,
|
||||||
|
);
|
||||||
assert_eq!(p["action"], "none");
|
assert_eq!(p["action"], "none");
|
||||||
assert_eq!(p["reason"], "no_hdr_base_layer");
|
assert_eq!(p["reason"], "no_hdr_base_layer");
|
||||||
assert_eq!(p["profile"], "P5");
|
assert_eq!(p["profile"], "P5");
|
||||||
let limitations = p["limitations"].as_array().unwrap();
|
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]
|
#[test]
|
||||||
fn sample_p7_dual_layer_hdr10_fallback() {
|
fn sample_p7_dual_layer_hdr10_fallback() {
|
||||||
// P7 BL+EL: stripping DVCC reveals the HDR10 base layer. Medium risk —
|
// P7 BL+EL: stripping DVCC reveals the HDR10 base layer. Medium risk —
|
||||||
// RPU NALs remain in-stream but HEVC decoders ignore them.
|
// RPU NALs remain in-stream but HEVC decoders ignore them.
|
||||||
let p = plan(r#"{
|
let p = plan(
|
||||||
|
r#"{
|
||||||
"stream": {
|
"stream": {
|
||||||
"name": "FLUX | 4K | dvhe.07.06 | Atmos",
|
"name": "FLUX | 4K | dvhe.07.06 | Atmos",
|
||||||
"description": "HDR10 + Dolby Vision P7 BL+EL remux",
|
"description": "HDR10 + Dolby Vision P7 BL+EL remux",
|
||||||
|
|
@ -1228,20 +1377,24 @@ mod tests {
|
||||||
"fallbackMode": "auto",
|
"fallbackMode": "auto",
|
||||||
"deviceHasDvDecoder": false,
|
"deviceHasDvDecoder": false,
|
||||||
"deviceHasDvDisplay": false
|
"deviceHasDvDisplay": false
|
||||||
}"#);
|
}"#,
|
||||||
|
);
|
||||||
assert_eq!(p["action"], "dvcc_strip");
|
assert_eq!(p["action"], "dvcc_strip");
|
||||||
assert_eq!(p["profile"], "P7");
|
assert_eq!(p["profile"], "P7");
|
||||||
assert_eq!(p["compatibility"], "HDR10");
|
assert_eq!(p["compatibility"], "HDR10");
|
||||||
assert_eq!(p["safety"], "medium");
|
assert_eq!(p["safety"], "medium");
|
||||||
let limitations = p["limitations"].as_array().unwrap();
|
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]
|
#[test]
|
||||||
fn sample_p8_1_single_layer_low_risk_fallback() {
|
fn sample_p8_1_single_layer_low_risk_fallback() {
|
||||||
// P8.1 has an HDR10-compatible base layer encoded into the single HEVC stream.
|
// P8.1 has an HDR10-compatible base layer encoded into the single HEVC stream.
|
||||||
// Stripping DVCC gives clean HDR10 output. Lowest-risk rewrite.
|
// Stripping DVCC gives clean HDR10 output. Lowest-risk rewrite.
|
||||||
let p = plan(r#"{
|
let p = plan(
|
||||||
|
r#"{
|
||||||
"stream": {
|
"stream": {
|
||||||
"name": "HDMUX | 4K | dvhe.08.01 | TrueHD Atmos",
|
"name": "HDMUX | 4K | dvhe.08.01 | TrueHD Atmos",
|
||||||
"dvProfile": 8,
|
"dvProfile": 8,
|
||||||
|
|
@ -1250,7 +1403,8 @@ mod tests {
|
||||||
"url": "https://debrid.example/Movie.2023.2160p.DV.HEVC.mkv",
|
"url": "https://debrid.example/Movie.2023.2160p.DV.HEVC.mkv",
|
||||||
"fallbackMode": "auto",
|
"fallbackMode": "auto",
|
||||||
"deviceHasDvDecoder": false
|
"deviceHasDvDecoder": false
|
||||||
}"#);
|
}"#,
|
||||||
|
);
|
||||||
assert_eq!(p["action"], "dvcc_strip");
|
assert_eq!(p["action"], "dvcc_strip");
|
||||||
assert_eq!(p["profile"], "P8.1");
|
assert_eq!(p["profile"], "P8.1");
|
||||||
assert_eq!(p["compatibility"], "HDR10");
|
assert_eq!(p["compatibility"], "HDR10");
|
||||||
|
|
@ -1261,7 +1415,8 @@ mod tests {
|
||||||
fn sample_p8_4_hlg_base_not_hdr10() {
|
fn sample_p8_4_hlg_base_not_hdr10() {
|
||||||
// P8.4 has an HLG base layer, not HDR10. Rewriting it as HDR10 would
|
// P8.4 has an HLG base layer, not HDR10. Rewriting it as HDR10 would
|
||||||
// produce incorrect colour. The compatibility field must reflect HLG.
|
// produce incorrect colour. The compatibility field must reflect HLG.
|
||||||
let p = plan(r#"{
|
let p = plan(
|
||||||
|
r#"{
|
||||||
"stream": {
|
"stream": {
|
||||||
"name": "BBC iPlayer | 4K | Dolby Vision HLG | AAC",
|
"name": "BBC iPlayer | 4K | Dolby Vision HLG | AAC",
|
||||||
"dvProfile": 8,
|
"dvProfile": 8,
|
||||||
|
|
@ -1270,12 +1425,15 @@ mod tests {
|
||||||
"url": "https://cdn.example/show_ep01.mkv",
|
"url": "https://cdn.example/show_ep01.mkv",
|
||||||
"fallbackMode": "auto",
|
"fallbackMode": "auto",
|
||||||
"deviceHasDvDecoder": false
|
"deviceHasDvDecoder": false
|
||||||
}"#);
|
}"#,
|
||||||
|
);
|
||||||
assert_eq!(p["action"], "dvcc_strip");
|
assert_eq!(p["action"], "dvcc_strip");
|
||||||
assert_eq!(p["profile"], "P8.4");
|
assert_eq!(p["profile"], "P8.4");
|
||||||
assert_eq!(p["compatibility"], "HLG");
|
assert_eq!(p["compatibility"], "HLG");
|
||||||
assert_ne!(p["compatibility"], "HDR10",
|
assert_ne!(
|
||||||
"P8.4 has HLG base, must not be labelled HDR10");
|
p["compatibility"], "HDR10",
|
||||||
|
"P8.4 has HLG base, must not be labelled HDR10"
|
||||||
|
);
|
||||||
assert_eq!(p["safety"], "medium");
|
assert_eq!(p["safety"], "medium");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1283,7 +1441,8 @@ mod tests {
|
||||||
fn sample_unknown_profile_from_addon_with_only_dv_keyword() {
|
fn sample_unknown_profile_from_addon_with_only_dv_keyword() {
|
||||||
// Many addons only set a "Dolby Vision" label without specifying the
|
// Many addons only set a "Dolby Vision" label without specifying the
|
||||||
// profile. Without profile info the only safe action is none.
|
// profile. Without profile info the only safe action is none.
|
||||||
let p = plan(r#"{
|
let p = plan(
|
||||||
|
r#"{
|
||||||
"stream": {
|
"stream": {
|
||||||
"name": "4K | Dolby Vision | DD+ Atmos",
|
"name": "4K | Dolby Vision | DD+ Atmos",
|
||||||
"description": "UHD Remux"
|
"description": "UHD Remux"
|
||||||
|
|
@ -1291,18 +1450,22 @@ mod tests {
|
||||||
"url": "https://debrid.example/movie.mkv",
|
"url": "https://debrid.example/movie.mkv",
|
||||||
"fallbackMode": "auto",
|
"fallbackMode": "auto",
|
||||||
"deviceHasDvDecoder": false
|
"deviceHasDvDecoder": false
|
||||||
}"#);
|
}"#,
|
||||||
|
);
|
||||||
assert_eq!(p["action"], "none");
|
assert_eq!(p["action"], "none");
|
||||||
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
|
assert_eq!(p["reason"], "unknown_profile_no_safe_fallback");
|
||||||
let limitations = p["limitations"].as_array().unwrap();
|
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]
|
#[test]
|
||||||
fn sample_p7_rpu_convert_on_raw_hevc_dv8_mode() {
|
fn sample_p7_rpu_convert_on_raw_hevc_dv8_mode() {
|
||||||
// Raw Annex-B HEVC + P7 + dv8 mode → live RPU conversion. The only
|
// Raw Annex-B HEVC + P7 + dv8 mode → live RPU conversion. The only
|
||||||
// case where rpu_convert is emitted instead of dvcc_strip.
|
// case where rpu_convert is emitted instead of dvcc_strip.
|
||||||
let p = plan(r#"{
|
let p = plan(
|
||||||
|
r#"{
|
||||||
"stream": {
|
"stream": {
|
||||||
"name": "RAW HEVC | 4K | dvhe.07.06",
|
"name": "RAW HEVC | 4K | dvhe.07.06",
|
||||||
"dvProfile": 7
|
"dvProfile": 7
|
||||||
|
|
@ -1310,7 +1473,8 @@ mod tests {
|
||||||
"url": "https://cdn.example/stream.hevc",
|
"url": "https://cdn.example/stream.hevc",
|
||||||
"fallbackMode": "dv8",
|
"fallbackMode": "dv8",
|
||||||
"deviceHasDvDecoder": false
|
"deviceHasDvDecoder": false
|
||||||
}"#);
|
}"#,
|
||||||
|
);
|
||||||
assert_eq!(p["action"], "rpu_convert");
|
assert_eq!(p["action"], "rpu_convert");
|
||||||
assert_eq!(p["profile"], "P7");
|
assert_eq!(p["profile"], "P7");
|
||||||
assert_eq!(p["compatibility"], "DV8");
|
assert_eq!(p["compatibility"], "DV8");
|
||||||
|
|
@ -1320,21 +1484,27 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn convert_dv81_p7_mkv_decoder_no_display_returns_rpu_convert() {
|
fn convert_dv81_p7_mkv_decoder_no_display_returns_rpu_convert() {
|
||||||
// Decoder present, no DV display: MKV now supported via EBML RPU rewriter.
|
// 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["action"], "rpu_convert");
|
||||||
assert_eq!(p["reason"], "p7_rpu_convert_to_dv81");
|
assert_eq!(p["reason"], "p7_rpu_convert_to_dv81");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn convert_dv81_p7_mp4_decoder_no_display_returns_rpu_convert() {
|
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["action"], "rpu_convert");
|
||||||
assert_eq!(p["reason"], "p7_rpu_convert_to_dv81");
|
assert_eq!(p["reason"], "p7_rpu_convert_to_dv81");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn convert_dv81_p7_raw_hevc_decoder_no_display_returns_rpu_convert() {
|
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["action"], "rpu_convert");
|
||||||
assert_eq!(p["reason"], "p7_rpu_convert_to_dv81");
|
assert_eq!(p["reason"], "p7_rpu_convert_to_dv81");
|
||||||
}
|
}
|
||||||
|
|
@ -1342,7 +1512,9 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn convert_dv81_decoder_and_display_returns_native_passthrough() {
|
fn convert_dv81_decoder_and_display_returns_native_passthrough() {
|
||||||
// Full DV device → native, no proxy needed.
|
// 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["action"], "none");
|
||||||
assert_eq!(p["reason"], "hw_dv_decoder");
|
assert_eq!(p["reason"], "hw_dv_decoder");
|
||||||
}
|
}
|
||||||
|
|
@ -1350,13 +1522,17 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn convert_dv81_no_decoder_falls_back_to_dvcc_strip() {
|
fn convert_dv81_no_decoder_falls_back_to_dvcc_strip() {
|
||||||
// No DV decoder → same as Auto: strip to HDR10.
|
// 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");
|
assert_eq!(p["action"], "dvcc_strip");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn convert_dv81_hls_still_deferred_to_manifest_rewrite() {
|
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["action"], "none");
|
||||||
assert_eq!(p["reason"], "manifest_handled");
|
assert_eq!(p["reason"], "manifest_handled");
|
||||||
}
|
}
|
||||||
|
|
@ -1365,7 +1541,8 @@ mod tests {
|
||||||
fn sample_hls_stream_always_deferred_to_manifest_rewrite() {
|
fn sample_hls_stream_always_deferred_to_manifest_rewrite() {
|
||||||
// HLS streams are handled by the OkHttp interceptor regardless of profile.
|
// HLS streams are handled by the OkHttp interceptor regardless of profile.
|
||||||
// The proxy must never be activated for .m3u8 URLs.
|
// The proxy must never be activated for .m3u8 URLs.
|
||||||
let p = plan(r#"{
|
let p = plan(
|
||||||
|
r#"{
|
||||||
"stream": {
|
"stream": {
|
||||||
"name": "Apple TV+ | 4K | dvhe.08.01",
|
"name": "Apple TV+ | 4K | dvhe.08.01",
|
||||||
"dvProfile": 8,
|
"dvProfile": 8,
|
||||||
|
|
@ -1374,7 +1551,8 @@ mod tests {
|
||||||
"url": "https://cdn.example/master.m3u8",
|
"url": "https://cdn.example/master.m3u8",
|
||||||
"fallbackMode": "auto",
|
"fallbackMode": "auto",
|
||||||
"deviceHasDvDecoder": false
|
"deviceHasDvDecoder": false
|
||||||
}"#);
|
}"#,
|
||||||
|
);
|
||||||
assert_eq!(p["action"], "none");
|
assert_eq!(p["action"], "none");
|
||||||
assert_eq!(p["reason"], "manifest_handled");
|
assert_eq!(p["reason"], "manifest_handled");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,11 @@ pub(crate) fn trakt_scrobble_plan_json(
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let progress = ((time_pos_sec / duration_sec) * 100.0).clamp(0.0, 100.0);
|
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 {
|
let body = if is_episode {
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"show": { "ids": ids },
|
"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 stored = request.stored_active_id.as_deref().unwrap_or("").trim();
|
||||||
let active = if stored.is_empty() || stored == GUEST_PROFILE_ID {
|
let active = if stored.is_empty() || stored == GUEST_PROFILE_ID {
|
||||||
request
|
request.profiles.first().cloned().unwrap_or(Value::Null)
|
||||||
.profiles
|
|
||||||
.first()
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or(Value::Null)
|
|
||||||
} else {
|
} else {
|
||||||
request
|
request
|
||||||
.profiles
|
.profiles
|
||||||
|
|
@ -106,7 +102,9 @@ pub(crate) fn token_merge_plan_json(request_json: &str) -> Option<String> {
|
||||||
match provider {
|
match provider {
|
||||||
"trakt" => {
|
"trakt" => {
|
||||||
let token = auth.get("accessToken").or_else(|| auth.get("access_token"));
|
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
|
let expires_at = auth
|
||||||
.get("expiresAt")
|
.get("expiresAt")
|
||||||
.or_else(|| auth.get("expires_at"))
|
.or_else(|| auth.get("expires_at"))
|
||||||
|
|
@ -124,7 +122,9 @@ pub(crate) fn token_merge_plan_json(request_json: &str) -> Option<String> {
|
||||||
}
|
}
|
||||||
"mal" => {
|
"mal" => {
|
||||||
let token = auth.get("accessToken").or_else(|| auth.get("access_token"));
|
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 {
|
if let Some(t) = token {
|
||||||
obj.insert("malAccessToken".to_string(), t.clone());
|
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_settings) = obj.remove("addonSettings") {
|
||||||
if let Some(addon_obj) = addon_settings.as_object() {
|
if let Some(addon_obj) = addon_settings.as_object() {
|
||||||
if let Some(local) = addon_obj.get("localAddons") {
|
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") {
|
if let Some(disabled) = addon_obj.get("disabledLocalAddons") {
|
||||||
obj.entry("disabledLocalAddons".to_string())
|
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)
|
.and_then(Value::as_array)
|
||||||
.is_some_and(|arr| !arr.is_empty());
|
.is_some_and(|arr| !arr.is_empty());
|
||||||
if !has_local_addons {
|
if !has_local_addons {
|
||||||
obj.insert(
|
obj.insert("localAddons".to_string(), json!([DEFAULT_ADDON_URL]));
|
||||||
"localAddons".to_string(),
|
|
||||||
json!([DEFAULT_ADDON_URL]),
|
|
||||||
);
|
|
||||||
applied.push("ensure_default_addon".to_string());
|
applied.push("ensure_default_addon".to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -379,10 +377,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn active_profile_plan_returns_first_when_no_stored_id() {
|
fn active_profile_plan_returns_first_when_no_stored_id() {
|
||||||
let result: Value = serde_json::from_str(
|
let result: Value = serde_json::from_str(
|
||||||
&active_profile_plan_json(
|
&active_profile_plan_json(r#"{"profiles":[{"id":"p1"},{"id":"p2"}]}"#).unwrap(),
|
||||||
r#"{"profiles":[{"id":"p1"},{"id":"p2"}]}"#,
|
|
||||||
)
|
|
||||||
.unwrap(),
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(result["activeId"], "p1");
|
assert_eq!(result["activeId"], "p1");
|
||||||
|
|
@ -391,10 +386,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn active_profile_plan_creates_default_when_profiles_empty() {
|
fn active_profile_plan_creates_default_when_profiles_empty() {
|
||||||
let result: Value = serde_json::from_str(
|
let result: Value =
|
||||||
&active_profile_plan_json(r#"{"profiles":[]}"#).unwrap(),
|
serde_json::from_str(&active_profile_plan_json(r#"{"profiles":[]}"#).unwrap()).unwrap();
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(result["activeId"], "guest");
|
assert_eq!(result["activeId"], "guest");
|
||||||
assert_eq!(result["shouldCreateDefault"], true);
|
assert_eq!(result["shouldCreateDefault"], true);
|
||||||
}
|
}
|
||||||
|
|
@ -434,10 +427,7 @@ mod tests {
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(result["migratedProfile"]["traktAccessToken"], "tok");
|
||||||
result["migratedProfile"]["traktAccessToken"],
|
|
||||||
"tok"
|
|
||||||
);
|
|
||||||
assert!(result["appliedMigrations"]
|
assert!(result["appliedMigrations"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
@ -455,7 +445,10 @@ mod tests {
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(result["migratedProfile"]["libraryCollections"][0]["id"], "c1");
|
assert_eq!(
|
||||||
|
result["migratedProfile"]["libraryCollections"][0]["id"],
|
||||||
|
"c1"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,9 @@ fn profile_safe_prefs(profile: &Value) -> ProfileSafePrefs {
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
ProfileSafePrefs {
|
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_percent,
|
||||||
subtitle_size: 20.0 * (subtitle_size_percent / 100.0),
|
subtitle_size: 20.0 * (subtitle_size_percent / 100.0),
|
||||||
subtitle_color: int(profile, "subtitleColor").unwrap_or(0xFFFF_FFFFu32 as i32 as i64),
|
subtitle_color: int(profile, "subtitleColor").unwrap_or(0xFFFF_FFFFu32 as i32 as i64),
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ pub enum EffectKind {
|
||||||
ReadDetailLocalState,
|
ReadDetailLocalState,
|
||||||
ReadDiscoverCatalogFilters,
|
ReadDiscoverCatalogFilters,
|
||||||
ReadHomeBootstrap,
|
ReadHomeBootstrap,
|
||||||
|
RefreshContinueWatching,
|
||||||
ReadLibraryState,
|
ReadLibraryState,
|
||||||
ReadPlaybackProgress,
|
ReadPlaybackProgress,
|
||||||
RefreshAuthToken,
|
RefreshAuthToken,
|
||||||
|
|
@ -78,6 +79,7 @@ impl EffectKind {
|
||||||
EffectKind::ReadDetailLocalState => "readDetailLocalState",
|
EffectKind::ReadDetailLocalState => "readDetailLocalState",
|
||||||
EffectKind::ReadDiscoverCatalogFilters => "readDiscoverCatalogFilters",
|
EffectKind::ReadDiscoverCatalogFilters => "readDiscoverCatalogFilters",
|
||||||
EffectKind::ReadHomeBootstrap => "readHomeBootstrap",
|
EffectKind::ReadHomeBootstrap => "readHomeBootstrap",
|
||||||
|
EffectKind::RefreshContinueWatching => "refreshContinueWatching",
|
||||||
EffectKind::ReadLibraryState => "readLibraryState",
|
EffectKind::ReadLibraryState => "readLibraryState",
|
||||||
EffectKind::ReadPlaybackProgress => "readPlaybackProgress",
|
EffectKind::ReadPlaybackProgress => "readPlaybackProgress",
|
||||||
EffectKind::RefreshAuthToken => "refreshAuthToken",
|
EffectKind::RefreshAuthToken => "refreshAuthToken",
|
||||||
|
|
@ -100,6 +102,7 @@ impl EffectKind {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::should_implement_trait)]
|
||||||
pub fn from_str(value: &str) -> Option<Self> {
|
pub fn from_str(value: &str) -> Option<Self> {
|
||||||
Some(match value {
|
Some(match value {
|
||||||
"clearPlaybackProgress" => EffectKind::ClearPlaybackProgress,
|
"clearPlaybackProgress" => EffectKind::ClearPlaybackProgress,
|
||||||
|
|
@ -125,6 +128,7 @@ impl EffectKind {
|
||||||
"readDetailLocalState" => EffectKind::ReadDetailLocalState,
|
"readDetailLocalState" => EffectKind::ReadDetailLocalState,
|
||||||
"readDiscoverCatalogFilters" => EffectKind::ReadDiscoverCatalogFilters,
|
"readDiscoverCatalogFilters" => EffectKind::ReadDiscoverCatalogFilters,
|
||||||
"readHomeBootstrap" => EffectKind::ReadHomeBootstrap,
|
"readHomeBootstrap" => EffectKind::ReadHomeBootstrap,
|
||||||
|
"refreshContinueWatching" => EffectKind::RefreshContinueWatching,
|
||||||
"readLibraryState" => EffectKind::ReadLibraryState,
|
"readLibraryState" => EffectKind::ReadLibraryState,
|
||||||
"readPlaybackProgress" => EffectKind::ReadPlaybackProgress,
|
"readPlaybackProgress" => EffectKind::ReadPlaybackProgress,
|
||||||
"refreshAuthToken" => EffectKind::RefreshAuthToken,
|
"refreshAuthToken" => EffectKind::RefreshAuthToken,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use serde::Deserialize;
|
|
||||||
use crate::{addon_protocol, content_identity};
|
use crate::{addon_protocol, content_identity};
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
|
@ -48,11 +48,14 @@ struct LibrarySortRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn manifest_value(addon: &Value) -> Option<&Value> {
|
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 {
|
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 {
|
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())
|
.filter(|part| !part.is_empty())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" ");
|
.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
|
label = label
|
||||||
.split_whitespace()
|
.split_whitespace()
|
||||||
.filter(|part| !part.eq_ignore_ascii_case(word))
|
.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 {
|
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<_>>())
|
let allowed_json =
|
||||||
.unwrap_or_else(|_| "[]".to_string());
|
serde_json::to_string(&allowed.iter().map(|s| s.to_string()).collect::<Vec<_>>())
|
||||||
|
.unwrap_or_else(|_| "[]".to_string());
|
||||||
serde_json::to_string(catalog)
|
serde_json::to_string(catalog)
|
||||||
.ok()
|
.ok()
|
||||||
.is_some_and(|json| addon_protocol::catalog_has_required_extra_except(&json, &allowed_json))
|
.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()
|
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 addons = serde_json::from_str::<Vec<Value>>(addons_json).ok()?;
|
||||||
let normalized_type = selected_type.to_lowercase();
|
let normalized_type = selected_type.to_lowercase();
|
||||||
let mut options = Vec::new();
|
let mut options = Vec::new();
|
||||||
|
|
@ -381,19 +390,29 @@ pub(crate) fn discover_sort_plan_json(request_json: &str) -> Option<String> {
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut seen_ids: HashSet<&str> = HashSet::with_capacity(filtered.len());
|
let mut seen_ids: HashSet<&str> = HashSet::with_capacity(filtered.len());
|
||||||
filtered.retain(|item| {
|
filtered.retain(|item| match item.get("id").and_then(Value::as_str) {
|
||||||
match item.get("id").and_then(Value::as_str) {
|
Some(id) => seen_ids.insert(id),
|
||||||
Some(id) => seen_ids.insert(id),
|
None => true,
|
||||||
None => true,
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
match sort_by {
|
match sort_by {
|
||||||
"year" => {
|
"year" => {
|
||||||
filtered.sort_by(|a, b| {
|
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 ya = a
|
||||||
let yb = b.get("releaseInfo").and_then(Value::as_str).and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
|
.get("releaseInfo")
|
||||||
if request.ascending { ya.cmp(&yb) } else { yb.cmp(&ya) }
|
.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" => {
|
"rating" => {
|
||||||
|
|
@ -411,7 +430,11 @@ pub(crate) fn discover_sort_plan_json(request_json: &str) -> Option<String> {
|
||||||
filtered.sort_by(|a, b| {
|
filtered.sort_by(|a, b| {
|
||||||
let na = a.get("name").and_then(Value::as_str).unwrap_or("");
|
let na = a.get("name").and_then(Value::as_str).unwrap_or("");
|
||||||
let nb = b.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> {
|
pub(crate) fn library_sort_plan_json(request_json: &str) -> Option<String> {
|
||||||
let request = serde_json::from_str::<LibrarySortRequest>(request_json).ok()?;
|
let request = serde_json::from_str::<LibrarySortRequest>(request_json).ok()?;
|
||||||
let type_filter = request.type_filter.as_deref().unwrap_or("").to_lowercase();
|
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 sort_by = request.sort_by.as_deref().unwrap_or("added");
|
||||||
|
|
||||||
let mut filtered: Vec<&Value> = request
|
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| {
|
filtered.sort_by(|a, b| {
|
||||||
let na = a.get("name").and_then(Value::as_str).unwrap_or("");
|
let na = a.get("name").and_then(Value::as_str).unwrap_or("");
|
||||||
let nb = b.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" => {
|
"year" => {
|
||||||
filtered.sort_by(|a, b| {
|
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 ya = a
|
||||||
let yb = b.get("releaseInfo").and_then(Value::as_str).and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
|
.get("releaseInfo")
|
||||||
if request.ascending { ya.cmp(&yb) } else { yb.cmp(&ya) }
|
.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" => {
|
"progress" => {
|
||||||
filtered.sort_by(|a, b| {
|
filtered.sort_by(|a, b| {
|
||||||
let pa = a.get("timeOffset").and_then(Value::as_i64).unwrap_or(0);
|
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);
|
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))
|
.take_while(|&&b| b.is_ascii_digit() || (b == b't' && start == 0))
|
||||||
.count();
|
.count();
|
||||||
let candidate = &raw[start..start + end];
|
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());
|
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 source: Value = serde_json::from_str(source_json).ok()?;
|
||||||
let addons: Vec<Value> = serde_json::from_str(addons_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 src_catalog_id = source.get("catalogId").and_then(Value::as_str)?;
|
||||||
let normalize_type = |v: &str| -> String {
|
let normalize_type = |v: &str| -> String {
|
||||||
match v.trim().to_lowercase().as_str() {
|
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(),
|
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 {
|
for addon in &addons {
|
||||||
let manifest = addon.get("manifest")?;
|
let manifest = addon.get("manifest")?;
|
||||||
let addon_id = manifest.get("id").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
let addon_id = manifest
|
||||||
let t_url = addon.get("transportUrl").and_then(Value::as_str).unwrap_or("");
|
.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 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;
|
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| {
|
let matches = catalogs.iter().any(|cat| {
|
||||||
cat.get("id").and_then(Value::as_str) == Some(src_catalog_id)
|
cat.get("id").and_then(Value::as_str) == Some(src_catalog_id)
|
||||||
&& src_type.as_deref().map_or(true, |st| {
|
&& src_type.as_deref().is_none_or(|st| {
|
||||||
cat.get("type").and_then(Value::as_str).map(|ct| normalize_type(ct)) == Some(st.to_string())
|
cat.get("type").and_then(Value::as_str).map(&normalize_type)
|
||||||
|
== Some(st.to_string())
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if matches {
|
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
|
/// 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
|
/// corresponding catalog's `extra` array for a `genre` field with a default or
|
||||||
/// first required value.
|
/// 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 option: Value = serde_json::from_str(feed_option_json).ok()?;
|
||||||
let addons: Vec<Value> = serde_json::from_str(addons_json).ok()?;
|
let addons: Vec<Value> = serde_json::from_str(addons_json).ok()?;
|
||||||
|
|
||||||
// If genre is already set on the option, return it.
|
// 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());
|
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_type = option.get("type").and_then(Value::as_str)?;
|
||||||
let opt_id = option.get("id").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 addon = addons
|
||||||
let catalogs = addon.get("manifest").and_then(|m| m.get("catalogs")).and_then(Value::as_array)?;
|
.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| {
|
let catalog = catalogs.iter().find(|cat| {
|
||||||
cat.get("type").and_then(Value::as_str) == Some(opt_type)
|
cat.get("type").and_then(Value::as_str) == Some(opt_type)
|
||||||
&& cat.get("id").and_then(Value::as_str) == Some(opt_id)
|
&& cat.get("id").and_then(Value::as_str) == Some(opt_id)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let extras = catalog.get("extra").and_then(Value::as_array)?;
|
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 default_genre = genre_extra
|
||||||
let is_required = genre_extra.get("isRequired").and_then(Value::as_bool).unwrap_or(false);
|
.get("default")
|
||||||
let first_option = genre_extra.get("options").and_then(Value::as_array)
|
.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(|opts| opts.first())
|
||||||
.and_then(Value::as_str);
|
.and_then(Value::as_str);
|
||||||
|
|
||||||
let resolved = default_genre
|
let resolved = default_genre.or(if is_required { first_option } else { None })?;
|
||||||
.or_else(|| if is_required { first_option } else { None })?;
|
|
||||||
Some(resolved.to_string())
|
Some(resolved.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -711,10 +802,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn season_load_plan_uses_saved_season_when_valid() {
|
fn season_load_plan_uses_saved_season_when_valid() {
|
||||||
let result: Value = serde_json::from_str(
|
let result: Value = serde_json::from_str(
|
||||||
&detail_season_load_plan_json(
|
&detail_season_load_plan_json(r#"{"savedVideoId":"tt1:3:2","seasonsCount":5}"#)
|
||||||
r#"{"savedVideoId":"tt1:3:2","seasonsCount":5}"#,
|
.unwrap(),
|
||||||
)
|
|
||||||
.unwrap(),
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(result["firstSeasonToLoad"], 3);
|
assert_eq!(result["firstSeasonToLoad"], 3);
|
||||||
|
|
@ -722,10 +811,9 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn season_load_plan_defaults_to_season_1_when_no_saved() {
|
fn season_load_plan_defaults_to_season_1_when_no_saved() {
|
||||||
let result: Value = serde_json::from_str(
|
let result: Value =
|
||||||
&detail_season_load_plan_json(r#"{"seasonsCount":5}"#).unwrap(),
|
serde_json::from_str(&detail_season_load_plan_json(r#"{"seasonsCount":5}"#).unwrap())
|
||||||
)
|
.unwrap();
|
||||||
.unwrap();
|
|
||||||
assert_eq!(result["firstSeasonToLoad"], 1);
|
assert_eq!(result["firstSeasonToLoad"], 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -546,7 +546,12 @@ pub(crate) fn subtitle_language_matches(
|
||||||
let normalized_preference = normalize_language_preference(preferred_language);
|
let normalized_preference = normalize_language_preference(preferred_language);
|
||||||
let word_regex =
|
let word_regex =
|
||||||
regex::Regex::new(&format!(r"\b{}\b", regex::escape(&normalized_preference))).ok();
|
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(
|
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"));
|
.or_else(|| preferred_subtitle_language.filter(|value| *value != "none"));
|
||||||
if let Some(preferred) = primary {
|
if let Some(preferred) = primary {
|
||||||
let norm = normalize_language_preference(preferred);
|
let norm = normalize_language_preference(preferred);
|
||||||
let word_regex =
|
let word_regex = regex::Regex::new(&format!(r"\b{}\b", regex::escape(&norm))).ok();
|
||||||
regex::Regex::new(&format!(r"\b{}\b", regex::escape(&norm))).ok();
|
|
||||||
if let Some(index) = tracks.iter().position(|track| {
|
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;
|
return index as i32;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(secondary) = secondary_subtitle_language.filter(|value| *value != "none") {
|
if let Some(secondary) = secondary_subtitle_language.filter(|value| *value != "none") {
|
||||||
let norm = normalize_language_preference(secondary);
|
let norm = normalize_language_preference(secondary);
|
||||||
let word_regex =
|
let word_regex = regex::Regex::new(&format!(r"\b{}\b", regex::escape(&norm))).ok();
|
||||||
regex::Regex::new(&format!(r"\b{}\b", regex::escape(&norm))).ok();
|
|
||||||
if let Some(index) = tracks.iter().position(|track| {
|
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;
|
return index as i32;
|
||||||
}
|
}
|
||||||
|
|
@ -739,12 +752,30 @@ fn stream_selection_item_from_value(v: &Value) -> StreamSelectionItem {
|
||||||
StreamSelectionItem {
|
StreamSelectionItem {
|
||||||
name: v.get("name").and_then(Value::as_str).map(str::to_string),
|
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),
|
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),
|
description: v
|
||||||
addon_name: v.get("addonName").and_then(Value::as_str).map(str::to_string),
|
.get("description")
|
||||||
playable_url: v.get("playableUrl").and_then(Value::as_str).map(str::to_string),
|
.and_then(Value::as_str)
|
||||||
binge_group: v.get("bingeGroup").and_then(Value::as_str).map(str::to_string),
|
.map(str::to_string),
|
||||||
filename: v.get("filename").and_then(Value::as_str).map(str::to_string),
|
addon_name: v
|
||||||
effective_filename: v.get("effectiveFilename").and_then(Value::as_str).map(str::to_string),
|
.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)
|
.unwrap_or(-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn select_stream_index_inner(
|
fn select_stream_index_inner(
|
||||||
streams: &[StreamSelectionItem],
|
streams: &[StreamSelectionItem],
|
||||||
current_video_id: &str,
|
current_video_id: &str,
|
||||||
|
|
@ -828,14 +860,28 @@ fn select_stream_index_inner(
|
||||||
match source_selection_mode {
|
match source_selection_mode {
|
||||||
STREAM_SOURCE_MODE_REGEX => {
|
STREAM_SOURCE_MODE_REGEX => {
|
||||||
let Some(pattern) = regex_pattern.filter(|value| !value.trim().is_empty()) else {
|
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)
|
let regex = match regex::RegexBuilder::new(pattern)
|
||||||
.case_insensitive(true)
|
.case_insensitive(true)
|
||||||
.build()
|
.build()
|
||||||
{
|
{
|
||||||
Ok(regex) => regex,
|
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| {
|
if let Some(index) = index_of_first_playable(streams, current_video_id, |stream| {
|
||||||
regex.is_match(&stream.selection_text())
|
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(
|
pub(crate) fn select_stream_index(
|
||||||
streams_json: &str,
|
streams_json: &str,
|
||||||
current_video_id: &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 {
|
let Ok(streams) = serde_json::from_str::<Vec<StreamSelectionItem>>(streams_json) else {
|
||||||
return -1;
|
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(
|
pub(crate) fn select_stream_index_values(
|
||||||
streams: &[Value],
|
streams: &[Value],
|
||||||
current_video_id: &str,
|
current_video_id: &str,
|
||||||
|
|
@ -880,8 +943,20 @@ pub(crate) fn select_stream_index_values(
|
||||||
regex_pattern: Option<&str>,
|
regex_pattern: Option<&str>,
|
||||||
preferred_binge_group: Option<&str>,
|
preferred_binge_group: Option<&str>,
|
||||||
) -> i32 {
|
) -> i32 {
|
||||||
let items: Vec<StreamSelectionItem> = streams.iter().map(stream_selection_item_from_value).collect();
|
let items: Vec<StreamSelectionItem> = streams
|
||||||
select_stream_index_inner(&items, current_video_id, initial_stream_index, saved_url, saved_title, source_selection_mode, regex_pattern, preferred_binge_group)
|
.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)]
|
#[cfg(test)]
|
||||||
|
|
@ -968,9 +1043,21 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_torrent_file_index_prefers_requested_then_filename_then_largest_video() {
|
fn resolve_torrent_file_index_prefers_requested_then_filename_then_largest_video() {
|
||||||
let stats = vec![
|
let stats = vec![
|
||||||
TorrentFileStat { id: 1, path: "Show.S01E01.mkv".to_string(), length: 100 },
|
TorrentFileStat {
|
||||||
TorrentFileStat { id: 2, path: "Show.S01E02.mkv".to_string(), length: 300 },
|
id: 1,
|
||||||
TorrentFileStat { id: 3, path: "sample.txt".to_string(), length: 999_999 },
|
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.
|
// 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()))
|
(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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,11 @@ use crate::constants::DEFAULT_LANGUAGE;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
pub(crate) fn tmdb_content_type(content_type: &str) -> &str {
|
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 {
|
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> {
|
pub(crate) fn tmdb_image_url(path: Option<&str>, size: &str) -> Option<String> {
|
||||||
let path = path?.trim();
|
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}"))
|
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 item: Value = serde_json::from_str(item_json).ok()?;
|
||||||
let id = item.get("id").and_then(Value::as_i64)?;
|
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 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 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 content_type = if requested_type == "series" || has_tv {
|
||||||
let name = item.get("title")
|
"series"
|
||||||
|
} else {
|
||||||
|
"movie"
|
||||||
|
};
|
||||||
|
let name = item
|
||||||
|
.get("title")
|
||||||
.or_else(|| item.get("name"))
|
.or_else(|| item.get("name"))
|
||||||
.or_else(|| item.get("original_name"))
|
.or_else(|| item.get("original_name"))
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or(if language == "tr" { "Bilinmeyen" } else { "Unknown" });
|
.unwrap_or(if language == "tr" {
|
||||||
let released = item.get("release_date").or_else(|| item.get("first_air_date"))
|
"Bilinmeyen"
|
||||||
|
} else {
|
||||||
|
"Unknown"
|
||||||
|
});
|
||||||
|
let released = item
|
||||||
|
.get("release_date")
|
||||||
|
.or_else(|| item.get("first_air_date"))
|
||||||
.and_then(Value::as_str);
|
.and_then(Value::as_str);
|
||||||
let poster = tmdb_image_url(item.get("poster_path").and_then(Value::as_str), "w500");
|
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");
|
let background = tmdb_image_url(
|
||||||
Some(serde_json::to_string(&json!({
|
item.get("backdrop_path").and_then(Value::as_str),
|
||||||
|
"original",
|
||||||
|
);
|
||||||
|
serde_json::to_string(&json!({
|
||||||
"id": format!("tmdb:{id}"),
|
"id": format!("tmdb:{id}"),
|
||||||
"type": content_type,
|
"type": content_type,
|
||||||
"name": name,
|
"name": name,
|
||||||
"poster": poster,
|
"poster": poster,
|
||||||
"background": background,
|
"background": background,
|
||||||
"releaseInfo": released.map(|r| r.get(..4).unwrap_or(r)),
|
"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> {
|
pub(crate) fn tmdb_video_to_trailer_json(video_json: &str) -> Option<String> {
|
||||||
let video: Value = serde_json::from_str(video_json).ok()?;
|
let video: Value = serde_json::from_str(video_json).ok()?;
|
||||||
let site = video.get("site").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
let site = video
|
||||||
if site != "youtube" { return None; }
|
.get("site")
|
||||||
let key = video.get("key").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty())?;
|
.and_then(Value::as_str)
|
||||||
let video_type = video.get("type").and_then(Value::as_str).map(str::trim).unwrap_or("Trailer");
|
.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();
|
let type_lower = video_type.to_lowercase();
|
||||||
if !["trailer", "teaser", "clip"].contains(&type_lower.as_str()) { return None; }
|
if !["trailer", "teaser", "clip"].contains(&type_lower.as_str()) {
|
||||||
let title = video.get("name").and_then(Value::as_str).map(str::trim)
|
return None;
|
||||||
.filter(|s| !s.is_empty()).unwrap_or(video_type);
|
}
|
||||||
Some(serde_json::to_string(&json!({
|
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}"),
|
"url": format!("https://www.youtube.com/watch?v={key}"),
|
||||||
"title": title,
|
"title": title,
|
||||||
"type": video_type,
|
"type": video_type,
|
||||||
})).ok()?)
|
}))
|
||||||
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn tmdb_bulk_metas_to_metas_json(
|
pub(crate) fn tmdb_bulk_metas_to_metas_json(
|
||||||
|
|
@ -68,7 +114,8 @@ pub(crate) fn tmdb_bulk_metas_to_metas_json(
|
||||||
language: &str,
|
language: &str,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
|
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| {
|
.filter_map(|item| {
|
||||||
let s = serde_json::to_string(item).ok()?;
|
let s = serde_json::to_string(item).ok()?;
|
||||||
let meta_json = tmdb_meta_to_meta_json(&s, requested_type, language)?;
|
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> {
|
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 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| {
|
.filter_map(|item| {
|
||||||
let s = serde_json::to_string(item).ok()?;
|
let s = serde_json::to_string(item).ok()?;
|
||||||
let json = tmdb_video_to_trailer_json(&s)?;
|
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
|
let local_ids: std::collections::HashSet<String> = request
|
||||||
.local_items
|
.local_items
|
||||||
.iter()
|
.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();
|
.collect();
|
||||||
let merged_external: Vec<&Value> = request
|
let merged_external: Vec<&Value> = request
|
||||||
.external_items
|
.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();
|
let mut valid_collections = Vec::<Value>::new();
|
||||||
for (i, col) in request.collections.iter().enumerate() {
|
for (i, col) in request.collections.iter().enumerate() {
|
||||||
let id = col.get("id").and_then(Value::as_str).unwrap_or("").trim();
|
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() {
|
if id.is_empty() {
|
||||||
issues.push(format!("collection[{}]: missing id", i));
|
issues.push(format!("collection[{}]: missing id", i));
|
||||||
continue;
|
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 existing_video_id = existing.get("lastVideoId").and_then(Value::as_str);
|
||||||
let incoming_video_id = incoming.get("lastVideoId").and_then(Value::as_str);
|
let incoming_video_id = incoming.get("lastVideoId").and_then(Value::as_str);
|
||||||
let video_changed =
|
let video_changed = incoming_video_id.is_some() && incoming_video_id != existing_video_id;
|
||||||
incoming_video_id.is_some() && incoming_video_id != existing_video_id;
|
|
||||||
|
|
||||||
let resolve_field = |key: &str| -> Value {
|
let resolve_field = |key: &str| -> Value {
|
||||||
incoming
|
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 {
|
let last_episode_name = if video_changed {
|
||||||
incoming.get("lastEpisodeName").cloned().unwrap_or(Value::Null)
|
incoming
|
||||||
|
.get("lastEpisodeName")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null)
|
||||||
} else {
|
} else {
|
||||||
resolve_field("lastEpisodeName")
|
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> {
|
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> {
|
fn cleaned_artwork_url(raw: Option<&str>) -> Option<String> {
|
||||||
let s = raw?.trim().trim_matches('\'').trim_matches('"').trim();
|
let s = raw?.trim().trim_matches('\'').trim_matches('"').trim();
|
||||||
if s.is_empty() { return None; }
|
if s.is_empty() {
|
||||||
let with_scheme = if s.starts_with("//") { format!("https:{s}") } else { s.to_string() };
|
return None;
|
||||||
let normalized = if let Some(caps) = regex::Regex::new(
|
}
|
||||||
r"^https://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.+)$"
|
let with_scheme = if s.starts_with("//") {
|
||||||
).ok().and_then(|re| re.captures(&with_scheme)) {
|
format!("https:{s}")
|
||||||
format!("https://raw.githubusercontent.com/{}/{}/{}/{}",
|
} else {
|
||||||
&caps[1], &caps[2], &caps[3], &caps[4])
|
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 {
|
} else {
|
||||||
with_scheme
|
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> {
|
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
|
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 folders: Vec<Value> = folders_raw.iter().map(|folder| {
|
||||||
let catalog_sources: Vec<Value> = folder.get("catalogSources")
|
let catalog_sources: Vec<Value> = folder.get("catalogSources")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
.filter(|arr| !arr.is_empty())
|
.filter(|arr| !arr.is_empty()).cloned()
|
||||||
.map(|arr| arr.clone())
|
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
if let Some(cid) = folder.get("catalogId").and_then(Value::as_str) {
|
if let Some(cid) = folder.get("catalogId").and_then(Value::as_str) {
|
||||||
vec![json!({ "catalogId": cid, "type": "movie" })]
|
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> {
|
pub(crate) fn library_apply_mark_watched_json(
|
||||||
use crate::library_state::{build_continue_watching_from_progress_json, remember_last_watched_episodes_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 updated_lib_str = remember_last_watched_episodes_json(lib_json, video_ids_json);
|
||||||
let mut lib: serde_json::Map<String, Value> = serde_json::from_str(&updated_lib_str).ok()?;
|
let 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 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();
|
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
|
let filtered: Vec<Value> = ext_cw
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|item| {
|
.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)
|
last_vid.is_empty() || !watched.contains(last_vid)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
lib.insert("externalContinueWatching".into(), filtered.into());
|
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
|
let cleaned: serde_json::Map<String, Value> = progress_map
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(_, entry)| {
|
.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)
|
last_vid.is_empty() || !watched.contains(last_vid)
|
||||||
})
|
})
|
||||||
.collect();
|
.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()
|
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 incoming: Value = serde_json::from_str(incoming_meta_json).unwrap_or(json!({}));
|
||||||
let existing: Value = serde_json::from_str(existing_meta_json).unwrap_or(json!({}));
|
let existing: Value = serde_json::from_str(existing_meta_json).unwrap_or(json!({}));
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue