mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-07-26 20:02:13 +00:00
Initial commit
This commit is contained in:
commit
504422871f
67 changed files with 22503 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/target/
|
||||||
|
CLAUDE.md
|
||||||
|
*.pdb
|
||||||
|
stremio-addon-api/
|
||||||
1485
Cargo.lock
generated
Normal file
1485
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
37
Cargo.toml
Normal file
37
Cargo.toml
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
[package]
|
||||||
|
name = "fluxa_core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "uniffi-bindgen"
|
||||||
|
path = "uniffi-bindgen.rs"
|
||||||
|
required-features = ["uniffi-cli"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["native"]
|
||||||
|
native = [
|
||||||
|
"dep:jni",
|
||||||
|
]
|
||||||
|
uniffi-cli = ["uniffi/cli"]
|
||||||
|
wasm = []
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
base64 = "0.22"
|
||||||
|
chrono = { version = "0.4.45", features = ["serde"] }
|
||||||
|
dolby_vision = { git = "https://github.com/quietvoid/dovi_tool.git", package = "dolby_vision", default-features = false }
|
||||||
|
jni = { version = "0.21", optional = true }
|
||||||
|
regex = "1"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
uniffi = "0.31.1"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = "debuginfo"
|
||||||
|
panic = "abort"
|
||||||
145
README.md
Normal file
145
README.md
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# fluxa-core
|
||||||
|
|
||||||
|
[![Contributors][contributors-shield]][contributors-url]
|
||||||
|
[![Forks][forks-shield]][forks-url]
|
||||||
|
[![Stars][stars-shield]][stars-url]
|
||||||
|
[![Issues][issues-shield]][issues-url]
|
||||||
|
[![License][license-shield]][license-url]
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The platform-agnostic Rust brain behind <a href="https://github.com/KhooLy/Fluxa">Fluxa</a>.<br/>
|
||||||
|
State management · Stream policy · Addon protocol · Effect-driven I/O
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is fluxa-core?
|
||||||
|
|
||||||
|
`fluxa-core` is a headless Rust library that contains all of Fluxa's domain logic. It handles content discovery, stream selection, playback state, user profiles, library management, calendar tracking, and external integrations (Trakt, Simkl) — with no platform-specific code inside Rust.
|
||||||
|
|
||||||
|
**Rust never calls the network.** Instead, the engine emits typed effects that the host platform (Kotlin on Android, or a native Rust runtime on desktop) executes and reports back. This keeps the same crate portable across Android (JNI), desktop (UniFFI), and future targets (WASM).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### The Effect Loop
|
||||||
|
|
||||||
|
```
|
||||||
|
Host (Kotlin / native) → dispatch(action_json)
|
||||||
|
← { state, effects: [{ id, type, payload }] }
|
||||||
|
Host → executes each effect (HTTP / storage / player / ...)
|
||||||
|
→ completeEffect({ effectId, result })
|
||||||
|
← { state, effects: [...] }
|
||||||
|
```
|
||||||
|
|
||||||
|
The host owns all I/O. The Rust engine owns all decisions.
|
||||||
|
|
||||||
|
### Binding Layers
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ fluxa-core (Rust) │
|
||||||
|
│ │
|
||||||
|
│ headless_engine ← domain modules │
|
||||||
|
│ ↓ │
|
||||||
|
│ ┌─────────────┬──────────────┐ │
|
||||||
|
│ │ JNI (jni) │ UniFFI │ │
|
||||||
|
│ │ Android │ Desktop/WASM │ │
|
||||||
|
│ └─────────────┴──────────────┘ │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
JNI bindings are compiled under the `native` feature (default). UniFFI bindings are always compiled and are the long-term cross-platform target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's Inside
|
||||||
|
|
||||||
|
| Module | Responsibility |
|
||||||
|
|--------|----------------|
|
||||||
|
| `headless_engine` | Central state machine — dispatches actions, owns pending effects |
|
||||||
|
| `addon_protocol` | Manifest URL normalisation, resource URL construction, manifest merging |
|
||||||
|
| `stream_policy` | Stream selection, magnet building, audio/subtitle preference matching |
|
||||||
|
| `player_policy` | Backend selection (ExoPlayer / MPV / external), track state |
|
||||||
|
| `player_flow` | Playback state machine (load → select → play → scrobble) |
|
||||||
|
| `content_identity` | ID parsing and normalisation (IMDB, TMDB, Kitsu, episode locators) |
|
||||||
|
| `home_ranking` | Billboard and shelf ordering, continue-watching deduplication |
|
||||||
|
| `library_state` | Continue-watching badge computation, next-episode resolution |
|
||||||
|
| `profile_contract` | Profile activation, auth token merging, settings migration |
|
||||||
|
| `profile_prefs` | Typed read of user preferences from raw profile JSON |
|
||||||
|
| `search_plan` | Search query planning, result grouping, discover sort |
|
||||||
|
| `calendar_plan` | Calendar item filtering, widget rows, release notifications |
|
||||||
|
| `external_sync` | Trakt and Simkl API response parsing and history mapping |
|
||||||
|
| `addon_store` | Addon search policy, CloudStream repo URL normalisation |
|
||||||
|
| `addon_resource` | Addon HTTP response classification (success / empty / error) |
|
||||||
|
| `intro_segments` | introdb.app and AniSkip segment parsing, deduplication |
|
||||||
|
| `dolby_vision_rpu` | Dolby Vision RPU metadata extraction for HDR stream selection |
|
||||||
|
| `platform_plan` | Season/episode navigation planning for the detail screen |
|
||||||
|
| `tmdb_plan` | TMDB ID resolution hints, trailer mapping |
|
||||||
|
| `discovery_plan` | Discover catalog request planning |
|
||||||
|
| `watchlist_plan` | Watchlist toggle, offline grouping, progress merging |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Rust toolchain (stable) — install via [rustup](https://rustup.rs/)
|
||||||
|
- For Android targets, the NDK and cross-compilation targets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
|
||||||
|
```
|
||||||
|
|
||||||
|
### Library
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Android (via Fluxa)
|
||||||
|
|
||||||
|
The Android app builds this crate as part of its Gradle build. See the [Fluxa](https://github.com/KhooLy/Fluxa) repo for the full build setup.
|
||||||
|
|
||||||
|
### UniFFI bindings (desktop)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release --features uniffi-cli
|
||||||
|
cargo run --features uniffi-cli --bin uniffi-bindgen generate \
|
||||||
|
--library target/release/libfluxa_core.so \
|
||||||
|
--language kotlin \
|
||||||
|
--out-dir bindings/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Used By
|
||||||
|
|
||||||
|
- [Fluxa](https://github.com/KhooLy/Fluxa) — Android media hub (Kotlin + Jetpack Compose)
|
||||||
|
- [FluxaDesktop](https://github.com/KhooLy/FluxaDesktop) — Desktop media hub (Rust + Tauri)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- MARKDOWN LINKS -->
|
||||||
|
[contributors-shield]: https://img.shields.io/github/contributors/KhooLy/fluxa-core.svg?style=for-the-badge
|
||||||
|
[contributors-url]: https://github.com/KhooLy/fluxa-core/graphs/contributors
|
||||||
|
[forks-shield]: https://img.shields.io/github/forks/KhooLy/fluxa-core.svg?style=for-the-badge
|
||||||
|
[forks-url]: https://github.com/KhooLy/fluxa-core/network/members
|
||||||
|
[stars-shield]: https://img.shields.io/github/stars/KhooLy/fluxa-core.svg?style=for-the-badge
|
||||||
|
[stars-url]: https://github.com/KhooLy/fluxa-core/stargazers
|
||||||
|
[issues-shield]: https://img.shields.io/github/issues/KhooLy/fluxa-core.svg?style=for-the-badge
|
||||||
|
[issues-url]: https://github.com/KhooLy/fluxa-core/issues
|
||||||
|
[license-shield]: https://img.shields.io/github/license/KhooLy/fluxa-core.svg?style=for-the-badge
|
||||||
|
[license-url]: https://github.com/KhooLy/fluxa-core/blob/master/LICENSE
|
||||||
972
src/addon_protocol.rs
Normal file
972
src/addon_protocol.rs
Normal file
|
|
@ -0,0 +1,972 @@
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
pub(crate) fn is_http_url(value: &str) -> bool {
|
||||||
|
let lower = value.to_ascii_lowercase();
|
||||||
|
lower.starts_with("http://") || lower.starts_with("https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn strip_http_scheme(value: &str) -> &str {
|
||||||
|
value
|
||||||
|
.strip_prefix("http://")
|
||||||
|
.or_else(|| value.strip_prefix("https://"))
|
||||||
|
.or_else(|| value.strip_prefix("HTTP://"))
|
||||||
|
.or_else(|| value.strip_prefix("HTTPS://"))
|
||||||
|
.unwrap_or(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_ipv4_like_host(value: &str) -> bool {
|
||||||
|
let host = value
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(value)
|
||||||
|
.split(':')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(value);
|
||||||
|
let parts: Vec<&str> = host.split('.').collect();
|
||||||
|
parts.len() == 4 && parts.iter().all(|part| part.parse::<u8>().is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_local_url(value: &str) -> bool {
|
||||||
|
let lower = strip_http_scheme(value).to_ascii_lowercase();
|
||||||
|
if lower.starts_with("localhost")
|
||||||
|
|| lower.starts_with("127.")
|
||||||
|
|| lower.starts_with("10.")
|
||||||
|
|| lower.starts_with("192.168.")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 172.16.0.0/12 private range
|
||||||
|
if let Some(rest) = lower.strip_prefix("172.") {
|
||||||
|
if let Some(second_octet) = rest.split('.').next().and_then(|s| s.parse::<u8>().ok()) {
|
||||||
|
if (16..=31).contains(&second_octet) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalize_manifest_url(raw: &str) -> String {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let lower = trimmed.to_ascii_lowercase();
|
||||||
|
let with_scheme = if lower.starts_with("stremio://") {
|
||||||
|
format!("https://{}", &trimmed[10..])
|
||||||
|
} else if lower.starts_with("http://") {
|
||||||
|
if is_local_url(trimmed) {
|
||||||
|
trimmed.to_string()
|
||||||
|
} else {
|
||||||
|
format!("https://{}", &trimmed[7..])
|
||||||
|
}
|
||||||
|
} else if lower.starts_with("https://") {
|
||||||
|
trimmed.to_string()
|
||||||
|
} else if lower.starts_with("127.0.0.1")
|
||||||
|
|| lower.starts_with("10.0.2.2")
|
||||||
|
|| lower.starts_with("localhost")
|
||||||
|
|| is_ipv4_like_host(trimmed)
|
||||||
|
{
|
||||||
|
format!("http://{trimmed}")
|
||||||
|
} else {
|
||||||
|
format!("https://{trimmed}")
|
||||||
|
};
|
||||||
|
|
||||||
|
if with_scheme.to_ascii_lowercase().ends_with("manifest.json") {
|
||||||
|
with_scheme
|
||||||
|
} else if with_scheme.ends_with('/') {
|
||||||
|
format!("{with_scheme}manifest.json")
|
||||||
|
} else {
|
||||||
|
format!("{with_scheme}/manifest.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn identity(raw: &str) -> String {
|
||||||
|
normalize_manifest_url(raw)
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.trim_start_matches("https://")
|
||||||
|
.trim_start_matches("http://")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn manifest_candidates(raw: &str) -> Vec<String> {
|
||||||
|
let normalized = normalize_manifest_url(raw);
|
||||||
|
let mut values = vec![normalized.clone()];
|
||||||
|
if is_local_url(&normalized) && normalized.to_ascii_lowercase().starts_with("https://") {
|
||||||
|
let fallback = format!("http://{}", &normalized[8..]);
|
||||||
|
if !values.contains(&fallback) {
|
||||||
|
values.push(fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn manifest_fetch_plan_json(raw: &str) -> Option<String> {
|
||||||
|
let normalized_transport_url = normalize_manifest_url(raw);
|
||||||
|
if normalized_transport_url.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"normalizedTransportUrl": normalized_transport_url,
|
||||||
|
"cacheKey": format!("manifest_v10_{}", normalized_transport_url),
|
||||||
|
"candidateUrls": manifest_candidates(&normalized_transport_url)
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn base_url(raw: &str) -> String {
|
||||||
|
let normalized = normalize_manifest_url(raw);
|
||||||
|
let without_manifest = match normalized.to_ascii_lowercase().rfind("manifest.json") {
|
||||||
|
Some(index) => normalized[..index].to_string(),
|
||||||
|
None => normalized,
|
||||||
|
};
|
||||||
|
let mut base = if without_manifest.ends_with('/') {
|
||||||
|
without_manifest
|
||||||
|
} else {
|
||||||
|
format!("{without_manifest}/")
|
||||||
|
};
|
||||||
|
let lower = base.to_ascii_lowercase();
|
||||||
|
if lower.contains("localhost") || lower.contains("127.0.0.1") {
|
||||||
|
if lower.starts_with("https://") {
|
||||||
|
base = format!("http://{}", &base[8..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prefer_https_asset_url(raw: &str) -> Option<String> {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let lower = trimmed.to_ascii_lowercase();
|
||||||
|
if lower.starts_with("http://") {
|
||||||
|
if is_local_url(trimmed) {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
} else {
|
||||||
|
Some(format!("https://{}", &trimmed[7..]))
|
||||||
|
}
|
||||||
|
} else if trimmed.starts_with("//") {
|
||||||
|
Some(format!("https:{trimmed}"))
|
||||||
|
} else {
|
||||||
|
Some(trimmed.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn encode_path_segment(value: &str) -> String {
|
||||||
|
let mut encoded = String::with_capacity(value.len());
|
||||||
|
for byte in value.bytes() {
|
||||||
|
let keep = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'*');
|
||||||
|
if keep {
|
||||||
|
encoded.push(byte as char);
|
||||||
|
} else {
|
||||||
|
encoded.push_str(&format!("%{byte:02X}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_resource_url(
|
||||||
|
raw: &str,
|
||||||
|
resource: &str,
|
||||||
|
content_type: &str,
|
||||||
|
id: &str,
|
||||||
|
extra_json: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
let extra_path = extra_json
|
||||||
|
.and_then(|value| serde_json::from_str::<Map<String, Value>>(value).ok())
|
||||||
|
.map(|map| {
|
||||||
|
map.into_iter()
|
||||||
|
.filter_map(|(key, value)| {
|
||||||
|
let text = value
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(|| value.to_string());
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(format!(
|
||||||
|
"{}={}",
|
||||||
|
encode_path_segment(&key),
|
||||||
|
encode_path_segment(&text)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("&")
|
||||||
|
})
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|value| format!("/{value}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!(
|
||||||
|
"{}{}/{}/{}{}.json",
|
||||||
|
base_url(raw),
|
||||||
|
resource,
|
||||||
|
encode_path_segment(content_type),
|
||||||
|
encode_path_segment(id),
|
||||||
|
extra_path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn string_array(json: &Value, key: &str) -> Vec<Value> {
|
||||||
|
json.get(key)
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
item.as_str()
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.map(|text| Value::String(text.to_string()))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn first_text(
|
||||||
|
json: &Value,
|
||||||
|
behavior_hints: Option<&Value>,
|
||||||
|
keys: &[&str],
|
||||||
|
) -> Option<String> {
|
||||||
|
keys.iter().find_map(|key| {
|
||||||
|
json.get(*key)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
behavior_hints
|
||||||
|
.and_then(|hints| hints.get(*key))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
})
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_asset_url(asset: Option<String>, manifest_url: &str) -> Option<String> {
|
||||||
|
let secure = prefer_https_asset_url(asset?.as_str())?;
|
||||||
|
if is_http_url(&secure) {
|
||||||
|
return Some(secure);
|
||||||
|
}
|
||||||
|
if secure.starts_with('/') {
|
||||||
|
let base = base_url(manifest_url);
|
||||||
|
let scheme_end = base.find("://").map(|index| index + 3)?;
|
||||||
|
let host_end = base[scheme_end..]
|
||||||
|
.find('/')
|
||||||
|
.map(|index| scheme_end + index)
|
||||||
|
.unwrap_or(base.len());
|
||||||
|
return prefer_https_asset_url(&format!("{}{}", &base[..host_end], secure));
|
||||||
|
}
|
||||||
|
prefer_https_asset_url(&format!("{}{}", base_url(manifest_url), secure))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_value<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
|
||||||
|
value
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty_array(value: Option<&Value>) -> Option<Value> {
|
||||||
|
value
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.filter(|items| !items.is_empty())
|
||||||
|
.cloned()
|
||||||
|
.map(Value::Array)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_or_null(map: &mut Map<String, Value>, key: &str, value: Option<String>) {
|
||||||
|
map.insert(
|
||||||
|
key.to_string(),
|
||||||
|
value.map(Value::String).unwrap_or(Value::Null),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_has_content(value: &Value) -> bool {
|
||||||
|
match value {
|
||||||
|
Value::Null => false,
|
||||||
|
Value::String(text) => !text.trim().is_empty(),
|
||||||
|
Value::Array(items) => !items.is_empty(),
|
||||||
|
Value::Object(map) => !map.is_empty(),
|
||||||
|
Value::Bool(_) | Value::Number(_) => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_manifest_assets_json(descriptor_json: &str) -> Option<String> {
|
||||||
|
let mut descriptor: Value = serde_json::from_str(descriptor_json).ok()?;
|
||||||
|
let transport_url = descriptor
|
||||||
|
.get("transportUrl")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let normalized_transport_url = normalize_manifest_url(&transport_url);
|
||||||
|
descriptor["transportUrl"] = Value::String(normalized_transport_url.clone());
|
||||||
|
|
||||||
|
let manifest = descriptor.get_mut("manifest")?.as_object_mut()?;
|
||||||
|
let logo = text_value(&Value::Object(manifest.clone()), "logo").map(str::to_string);
|
||||||
|
let background = text_value(&Value::Object(manifest.clone()), "background").map(str::to_string);
|
||||||
|
let resolved_background = resolve_asset_url(background.clone(), &normalized_transport_url);
|
||||||
|
let resolved_logo =
|
||||||
|
resolve_asset_url(logo, &normalized_transport_url).or_else(|| resolved_background.clone());
|
||||||
|
let description = manifest
|
||||||
|
.get("description")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.map(str::to_string);
|
||||||
|
|
||||||
|
set_or_null(manifest, "description", description);
|
||||||
|
set_or_null(manifest, "logo", resolved_logo);
|
||||||
|
set_or_null(manifest, "background", resolved_background);
|
||||||
|
serde_json::to_string(&descriptor).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn merge_live_manifest_json(
|
||||||
|
descriptor_json: &str,
|
||||||
|
live_json: Option<&str>,
|
||||||
|
unknown_name: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let Some(live_json) = live_json.filter(|value| !value.trim().is_empty()) else {
|
||||||
|
return resolve_manifest_assets_json(descriptor_json);
|
||||||
|
};
|
||||||
|
let mut descriptor: Value = serde_json::from_str(descriptor_json).ok()?;
|
||||||
|
let live: Value = serde_json::from_str(live_json).ok()?;
|
||||||
|
let transport_url = descriptor
|
||||||
|
.get("transportUrl")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let normalized_transport_url = normalize_manifest_url(&transport_url);
|
||||||
|
descriptor["transportUrl"] = Value::String(normalized_transport_url.clone());
|
||||||
|
|
||||||
|
let current_manifest_snapshot = descriptor.get("manifest")?.clone();
|
||||||
|
let current_manifest = current_manifest_snapshot.as_object()?;
|
||||||
|
let live_manifest = live.get("manifest")?.as_object()?;
|
||||||
|
let manifest = descriptor.get_mut("manifest")?.as_object_mut()?;
|
||||||
|
|
||||||
|
if let Some(id) = text_value(&Value::Object(live_manifest.clone()), "id") {
|
||||||
|
manifest.insert("id".to_string(), Value::String(id.to_string()));
|
||||||
|
}
|
||||||
|
if let Some(name) = text_value(&Value::Object(live_manifest.clone()), "name")
|
||||||
|
.filter(|name| *name != unknown_name)
|
||||||
|
{
|
||||||
|
manifest.insert("name".to_string(), Value::String(name.to_string()));
|
||||||
|
}
|
||||||
|
if let Some(description) = text_value(&Value::Object(live_manifest.clone()), "description") {
|
||||||
|
manifest.insert(
|
||||||
|
"description".to_string(),
|
||||||
|
Value::String(description.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (key, value) in live_manifest {
|
||||||
|
if matches!(
|
||||||
|
key.as_str(),
|
||||||
|
"id" | "name" | "description" | "logo" | "background"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if matches!(
|
||||||
|
key.as_str(),
|
||||||
|
"resources" | "types" | "catalogs" | "idPrefixes"
|
||||||
|
) {
|
||||||
|
if let Some(value) = non_empty_array(Some(value)) {
|
||||||
|
manifest.insert(key.to_string(), value);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if value_has_content(value) {
|
||||||
|
manifest.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let current_logo = current_manifest
|
||||||
|
.get("logo")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string);
|
||||||
|
let current_background = current_manifest
|
||||||
|
.get("background")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string);
|
||||||
|
let live_logo = live_manifest
|
||||||
|
.get("logo")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.map(str::to_string);
|
||||||
|
let live_background = live_manifest
|
||||||
|
.get("background")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.map(str::to_string);
|
||||||
|
|
||||||
|
let resolved_current_logo = resolve_asset_url(current_logo, &normalized_transport_url);
|
||||||
|
let resolved_current_background =
|
||||||
|
resolve_asset_url(current_background, &normalized_transport_url);
|
||||||
|
let logo = live_logo
|
||||||
|
.or(resolved_current_logo)
|
||||||
|
.or_else(|| live_background.clone())
|
||||||
|
.or_else(|| resolved_current_background.clone());
|
||||||
|
let background = live_background.or(resolved_current_background);
|
||||||
|
set_or_null(manifest, "logo", logo);
|
||||||
|
set_or_null(manifest, "background", background);
|
||||||
|
|
||||||
|
serde_json::to_string(&descriptor).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_catalogs(json: &Value) -> Vec<Value> {
|
||||||
|
json.get("catalogs")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
let object = item.as_object()?;
|
||||||
|
let mut map = object.clone();
|
||||||
|
let extra = object
|
||||||
|
.get("extra")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|extras| {
|
||||||
|
extras
|
||||||
|
.iter()
|
||||||
|
.filter_map(|extra| {
|
||||||
|
let extra_object = extra.as_object()?;
|
||||||
|
let mut map = Map::new();
|
||||||
|
if let Some(name) = extra_object
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
{
|
||||||
|
map.insert(
|
||||||
|
"name".to_string(),
|
||||||
|
Value::String(name.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let options = string_array(extra, "options");
|
||||||
|
if !options.is_empty() {
|
||||||
|
map.insert("options".to_string(), Value::Array(options));
|
||||||
|
}
|
||||||
|
if let Some(is_required) =
|
||||||
|
extra_object.get("isRequired").and_then(Value::as_bool)
|
||||||
|
{
|
||||||
|
map.insert(
|
||||||
|
"isRequired".to_string(),
|
||||||
|
Value::Bool(is_required),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(options_limit) =
|
||||||
|
extra_object.get("optionsLimit").and_then(Value::as_i64)
|
||||||
|
{
|
||||||
|
map.insert(
|
||||||
|
"optionsLimit".to_string(),
|
||||||
|
json!(options_limit as i32),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(Value::Object(map))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !extra.is_empty() {
|
||||||
|
map.insert("extra".to_string(), Value::Array(extra));
|
||||||
|
}
|
||||||
|
Some(Value::Object(map))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_manifest(
|
||||||
|
body: &str,
|
||||||
|
transport_url: &str,
|
||||||
|
unknown_name: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let json: Value = serde_json::from_str(body).ok()?;
|
||||||
|
let behavior_hints = json.get("behaviorHints");
|
||||||
|
let logo = first_text(
|
||||||
|
&json,
|
||||||
|
behavior_hints,
|
||||||
|
&["logo", "icon", "iconUrl", "poster", "posterUrl"],
|
||||||
|
);
|
||||||
|
let background = first_text(
|
||||||
|
&json,
|
||||||
|
behavior_hints,
|
||||||
|
&["background", "backgroundUrl", "backdrop", "backdropUrl"],
|
||||||
|
);
|
||||||
|
let description = first_text(
|
||||||
|
&json,
|
||||||
|
behavior_hints,
|
||||||
|
&[
|
||||||
|
"description",
|
||||||
|
"shortDescription",
|
||||||
|
"longDescription",
|
||||||
|
"summary",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let resources = json
|
||||||
|
.get("resources")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let id_prefixes = string_array(&json, "idPrefixes");
|
||||||
|
let mut manifest = json.as_object().cloned().unwrap_or_default();
|
||||||
|
manifest.insert(
|
||||||
|
"id".to_string(),
|
||||||
|
Value::String(
|
||||||
|
json.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
manifest.insert(
|
||||||
|
"name".to_string(),
|
||||||
|
Value::String(
|
||||||
|
json.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.unwrap_or(unknown_name)
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
manifest.insert(
|
||||||
|
"description".to_string(),
|
||||||
|
description.map(Value::String).unwrap_or(Value::Null),
|
||||||
|
);
|
||||||
|
manifest.insert(
|
||||||
|
"version".to_string(),
|
||||||
|
first_text(&json, behavior_hints, &["version"])
|
||||||
|
.map(Value::String)
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
);
|
||||||
|
manifest.insert("resources".to_string(), Value::Array(resources));
|
||||||
|
manifest.insert(
|
||||||
|
"types".to_string(),
|
||||||
|
Value::Array(string_array(&json, "types")),
|
||||||
|
);
|
||||||
|
manifest.insert("catalogs".to_string(), Value::Array(parse_catalogs(&json)));
|
||||||
|
manifest.insert(
|
||||||
|
"idPrefixes".to_string(),
|
||||||
|
if id_prefixes.is_empty() {
|
||||||
|
Value::Null
|
||||||
|
} else {
|
||||||
|
Value::Array(id_prefixes)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
manifest.insert(
|
||||||
|
"logo".to_string(),
|
||||||
|
resolve_asset_url(logo, transport_url)
|
||||||
|
.map(Value::String)
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
);
|
||||||
|
manifest.insert(
|
||||||
|
"background".to_string(),
|
||||||
|
resolve_asset_url(background, transport_url)
|
||||||
|
.map(Value::String)
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
);
|
||||||
|
manifest.insert(
|
||||||
|
"configurable".to_string(),
|
||||||
|
behavior_hints
|
||||||
|
.and_then(|hints| hints.get("configurable"))
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.map(Value::Bool)
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
);
|
||||||
|
|
||||||
|
let descriptor = json!({
|
||||||
|
"manifest": Value::Object(manifest),
|
||||||
|
"transportUrl": transport_url
|
||||||
|
});
|
||||||
|
serde_json::to_string(&descriptor).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn canonical_resource_name(value: &str) -> String {
|
||||||
|
match value.to_ascii_lowercase().trim_end_matches('s') {
|
||||||
|
"metadata" => "meta".to_string(),
|
||||||
|
"subtitle" => "subtitle".to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn to_string_vec(value: Option<&Value>) -> Vec<String> {
|
||||||
|
match value {
|
||||||
|
Some(Value::Array(items)) => items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item.as_str().map(str::to_string))
|
||||||
|
.filter(|item| !item.is_empty())
|
||||||
|
.collect(),
|
||||||
|
Some(Value::String(text)) if !text.is_empty() => vec![text.to_string()],
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn supports_resource(
|
||||||
|
manifest_json: &str,
|
||||||
|
resource_name: &str,
|
||||||
|
content_type: Option<&str>,
|
||||||
|
id: Option<&str>,
|
||||||
|
) -> bool {
|
||||||
|
let Ok(manifest) = serde_json::from_str::<Value>(manifest_json) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let expected = canonical_resource_name(resource_name);
|
||||||
|
let manifest_types = to_string_vec(manifest.get("types"));
|
||||||
|
let manifest_prefixes = to_string_vec(manifest.get("idPrefixes"));
|
||||||
|
manifest
|
||||||
|
.get("resources")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|resources| {
|
||||||
|
resources.iter().any(|resource| {
|
||||||
|
let (name, types, prefixes) = match resource {
|
||||||
|
Value::String(name) => (
|
||||||
|
name.as_str(),
|
||||||
|
manifest_types.clone(),
|
||||||
|
manifest_prefixes.clone(),
|
||||||
|
),
|
||||||
|
Value::Object(map) => {
|
||||||
|
let Some(name) = map.get("name").and_then(Value::as_str) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let types = to_string_vec(map.get("types"))
|
||||||
|
.into_iter()
|
||||||
|
.chain(to_string_vec(map.get("type")))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let prefixes = to_string_vec(map.get("idPrefixes"))
|
||||||
|
.into_iter()
|
||||||
|
.chain(to_string_vec(map.get("idPrefix")))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
(
|
||||||
|
name,
|
||||||
|
if types.is_empty() {
|
||||||
|
manifest_types.clone()
|
||||||
|
} else {
|
||||||
|
types
|
||||||
|
},
|
||||||
|
if prefixes.is_empty() {
|
||||||
|
manifest_prefixes.clone()
|
||||||
|
} else {
|
||||||
|
prefixes
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
if canonical_resource_name(name) != expected {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(content_type) = content_type {
|
||||||
|
if !types.is_empty()
|
||||||
|
&& !types
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.eq_ignore_ascii_case(content_type))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(id) = id {
|
||||||
|
if canonical_resource_name(name) != "catalog"
|
||||||
|
&& !prefixes.is_empty()
|
||||||
|
&& !prefixes.iter().any(|prefix| id.starts_with(prefix))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn catalog_supports_extra(catalog_json: &str, extra_name: &str) -> bool {
|
||||||
|
let Ok(catalog) = serde_json::from_str::<Value>(catalog_json) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
catalog
|
||||||
|
.get("extraSupported")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.any(|item| item.eq_ignore_ascii_case(extra_name))
|
||||||
|
})
|
||||||
|
|| catalog
|
||||||
|
.get("extra")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|items| {
|
||||||
|
items.iter().any(|item| {
|
||||||
|
item.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|name| name.eq_ignore_ascii_case(extra_name))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn catalog_requires_extra(catalog_json: &str, extra_name: &str) -> bool {
|
||||||
|
let Ok(catalog) = serde_json::from_str::<Value>(catalog_json) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
catalog
|
||||||
|
.get("extra")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|items| {
|
||||||
|
items.iter().any(|item| {
|
||||||
|
item.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|name| name.eq_ignore_ascii_case(extra_name))
|
||||||
|
&& item.get("isRequired").and_then(Value::as_bool) == Some(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn catalog_has_required_extra_except(
|
||||||
|
catalog_json: &str,
|
||||||
|
allowed_names_json: &str,
|
||||||
|
) -> bool {
|
||||||
|
let Ok(catalog) = serde_json::from_str::<Value>(catalog_json) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let allowed_names = serde_json::from_str::<Vec<String>>(allowed_names_json)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
catalog
|
||||||
|
.get("extra")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|items| {
|
||||||
|
items.iter().any(|item| {
|
||||||
|
item.get("isRequired").and_then(Value::as_bool) == Some(true)
|
||||||
|
&& item
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|name| !allowed_names.contains(&name.to_ascii_lowercase()))
|
||||||
|
.unwrap_or(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{merge_live_manifest_json, parse_manifest, resolve_manifest_assets_json};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_manifest_assets_normalizes_transport_and_relative_assets() {
|
||||||
|
let descriptor = r#"{
|
||||||
|
"transportUrl":"addon.example/root/manifest.json",
|
||||||
|
"manifest":{
|
||||||
|
"id":"addon",
|
||||||
|
"name":"Addon",
|
||||||
|
"description":"",
|
||||||
|
"resources":[],
|
||||||
|
"types":[],
|
||||||
|
"catalogs":[],
|
||||||
|
"logo":"logo.png",
|
||||||
|
"background":"/bg.jpg"
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let resolved = resolve_manifest_assets_json(descriptor)
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.expect("resolved descriptor");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolved.get("transportUrl").and_then(Value::as_str),
|
||||||
|
Some("https://addon.example/root/manifest.json")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolved
|
||||||
|
.get("manifest")
|
||||||
|
.and_then(|manifest| manifest.get("description")),
|
||||||
|
Some(&Value::Null)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolved
|
||||||
|
.get("manifest")
|
||||||
|
.and_then(|manifest| manifest.get("logo"))
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("https://addon.example/root/logo.png")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolved
|
||||||
|
.get("manifest")
|
||||||
|
.and_then(|manifest| manifest.get("background"))
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("https://addon.example/bg.jpg")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_fetch_plan_owns_cache_key_and_candidates() {
|
||||||
|
let plan = super::manifest_fetch_plan_json("127.0.0.1:7000")
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.expect("manifest fetch plan");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
plan.get("normalizedTransportUrl").and_then(Value::as_str),
|
||||||
|
Some("http://127.0.0.1:7000/manifest.json")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plan.get("cacheKey").and_then(Value::as_str),
|
||||||
|
Some("manifest_v10_http://127.0.0.1:7000/manifest.json")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
plan.get("candidateUrls")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|items| items.first())
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("http://127.0.0.1:7000/manifest.json")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_live_manifest_keeps_current_fields_when_live_is_empty() {
|
||||||
|
let current = r#"{
|
||||||
|
"transportUrl":"https://addon.example/manifest.json",
|
||||||
|
"manifest":{
|
||||||
|
"id":"old",
|
||||||
|
"name":"Old",
|
||||||
|
"description":"Current",
|
||||||
|
"version":"1.0",
|
||||||
|
"resources":["stream"],
|
||||||
|
"types":["movie"],
|
||||||
|
"catalogs":[{"type":"movie","id":"old"}],
|
||||||
|
"logo":"logo.png",
|
||||||
|
"background":"bg.jpg",
|
||||||
|
"configurable":false
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let live = r#"{
|
||||||
|
"transportUrl":"https://addon.example/manifest.json",
|
||||||
|
"manifest":{
|
||||||
|
"id":"new",
|
||||||
|
"name":"Unknown",
|
||||||
|
"description":"",
|
||||||
|
"version":"2.0",
|
||||||
|
"resources":[],
|
||||||
|
"types":[],
|
||||||
|
"catalogs":[],
|
||||||
|
"logo":null,
|
||||||
|
"background":"live-bg.jpg",
|
||||||
|
"configurable":true
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let merged = merge_live_manifest_json(current, Some(live), "Unknown")
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.expect("merged descriptor");
|
||||||
|
let manifest = merged.get("manifest").expect("manifest");
|
||||||
|
|
||||||
|
assert_eq!(manifest.get("id").and_then(Value::as_str), Some("new"));
|
||||||
|
assert_eq!(manifest.get("name").and_then(Value::as_str), Some("Old"));
|
||||||
|
assert_eq!(
|
||||||
|
manifest.get("description").and_then(Value::as_str),
|
||||||
|
Some("Current")
|
||||||
|
);
|
||||||
|
assert_eq!(manifest.get("version").and_then(Value::as_str), Some("2.0"));
|
||||||
|
assert_eq!(
|
||||||
|
manifest
|
||||||
|
.get("resources")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(Vec::len),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.get("logo").and_then(Value::as_str),
|
||||||
|
Some("https://addon.example/logo.png")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.get("background").and_then(Value::as_str),
|
||||||
|
Some("live-bg.jpg")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest.get("configurable").and_then(Value::as_bool),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_manifest_preserves_stremio_manifest_fields() {
|
||||||
|
let parsed = parse_manifest(
|
||||||
|
r#"{
|
||||||
|
"id":"org.example.full",
|
||||||
|
"version":"1.2.3",
|
||||||
|
"name":"Full",
|
||||||
|
"description":"All fields",
|
||||||
|
"resources":["catalog",{"name":"stream","types":["movie"],"idPrefixes":["tt"]}],
|
||||||
|
"types":["movie","series"],
|
||||||
|
"idPrefixes":["tt"],
|
||||||
|
"catalogs":[{"type":"movie","id":"top","name":"Top","extra":[{"name":"genre","isRequired":false,"options":["Drama"],"optionsLimit":2}],"extraSupported":["search"],"extraRequired":["genre"]}],
|
||||||
|
"addonCatalogs":[{"type":"addon","id":"community","name":"Community"}],
|
||||||
|
"config":[{"key":"token","type":"password","default":"x","title":"Token","required":true}],
|
||||||
|
"background":"/bg.jpg",
|
||||||
|
"logo":"logo.png",
|
||||||
|
"contactEmail":"ops@example.com",
|
||||||
|
"behaviorHints":{"adult":true,"p2p":true,"configurable":true,"configurationRequired":true}
|
||||||
|
}"#,
|
||||||
|
"https://addon.example/root/manifest.json",
|
||||||
|
"Unknown",
|
||||||
|
)
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.expect("parsed manifest");
|
||||||
|
let manifest = parsed.get("manifest").expect("manifest");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
manifest["addonCatalogs"][0]["id"].as_str(),
|
||||||
|
Some("community")
|
||||||
|
);
|
||||||
|
assert_eq!(manifest["config"][0]["key"].as_str(), Some("token"));
|
||||||
|
assert_eq!(manifest["contactEmail"].as_str(), Some("ops@example.com"));
|
||||||
|
assert_eq!(
|
||||||
|
manifest["behaviorHints"]["configurationRequired"].as_bool(),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest["catalogs"][0]["extraRequired"][0].as_str(),
|
||||||
|
Some("genre")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest["logo"].as_str(),
|
||||||
|
Some("https://addon.example/root/logo.png")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
manifest["background"].as_str(),
|
||||||
|
Some("https://addon.example/bg.jpg")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_live_manifest_preserves_new_live_manifest_fields() {
|
||||||
|
let current = r#"{
|
||||||
|
"transportUrl":"https://addon.example/manifest.json",
|
||||||
|
"manifest":{"id":"old","name":"Old","description":"Current","resources":["stream"],"types":["movie"],"catalogs":[]}
|
||||||
|
}"#;
|
||||||
|
let live = r#"{
|
||||||
|
"manifest":{
|
||||||
|
"id":"old",
|
||||||
|
"name":"Live",
|
||||||
|
"description":"Live description",
|
||||||
|
"resources":["stream"],
|
||||||
|
"types":["movie"],
|
||||||
|
"catalogs":[],
|
||||||
|
"addonCatalogs":[{"type":"addon","id":"community","name":"Community"}],
|
||||||
|
"config":[{"key":"token","type":"password"}],
|
||||||
|
"contactEmail":"ops@example.com",
|
||||||
|
"behaviorHints":{"configurable":true,"configurationRequired":true}
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let merged = merge_live_manifest_json(current, Some(live), "Unknown")
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.expect("merged manifest");
|
||||||
|
let manifest = merged.get("manifest").expect("manifest");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
manifest["addonCatalogs"][0]["id"].as_str(),
|
||||||
|
Some("community")
|
||||||
|
);
|
||||||
|
assert_eq!(manifest["config"][0]["key"].as_str(), Some("token"));
|
||||||
|
assert_eq!(manifest["contactEmail"].as_str(), Some("ops@example.com"));
|
||||||
|
assert_eq!(
|
||||||
|
manifest["behaviorHints"]["configurationRequired"].as_bool(),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
286
src/addon_resource.rs
Normal file
286
src/addon_resource.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
use crate::addon_protocol;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
fn resource_payload<'a>(resource: &str, root: &'a Value) -> Option<Value> {
|
||||||
|
match resource {
|
||||||
|
"stream" | "streams" => root.get("streams").cloned(),
|
||||||
|
"catalog" | "metas" => root.get("metas").cloned(),
|
||||||
|
"meta" => root.get("meta").cloned(),
|
||||||
|
"subtitles" | "subtitle" => root.get("subtitles").cloned(),
|
||||||
|
other => root.get(other).cloned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn payload_is_empty(payload: &Value) -> bool {
|
||||||
|
match payload {
|
||||||
|
Value::Null => true,
|
||||||
|
Value::Array(values) => values.is_empty(),
|
||||||
|
Value::Object(values) => values.is_empty(),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_value(root: &Value, key: &str) -> Value {
|
||||||
|
root.get(key)
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.map(|value| json!(value))
|
||||||
|
.unwrap_or(Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_addon_resource_result_json(
|
||||||
|
resource: &str,
|
||||||
|
url: &str,
|
||||||
|
status_code: i32,
|
||||||
|
body: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
if !(200..=299).contains(&status_code) {
|
||||||
|
return json!({
|
||||||
|
"kind": "network_error",
|
||||||
|
"url": url,
|
||||||
|
"statusCode": status_code
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(body) = body.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||||
|
return json!({
|
||||||
|
"kind": "empty",
|
||||||
|
"url": url,
|
||||||
|
"statusCode": status_code
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
};
|
||||||
|
|
||||||
|
let root: Value = match serde_json::from_str(body) {
|
||||||
|
Ok(root) => root,
|
||||||
|
Err(error) => {
|
||||||
|
return json!({
|
||||||
|
"kind": "parse_error",
|
||||||
|
"url": url,
|
||||||
|
"statusCode": status_code,
|
||||||
|
"error": error.to_string()
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(payload) = resource_payload(resource, &root) else {
|
||||||
|
return json!({
|
||||||
|
"kind": "empty",
|
||||||
|
"url": url,
|
||||||
|
"statusCode": status_code
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
};
|
||||||
|
|
||||||
|
if payload_is_empty(&payload) {
|
||||||
|
return json!({
|
||||||
|
"kind": "empty",
|
||||||
|
"url": url,
|
||||||
|
"statusCode": status_code
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"kind": "success",
|
||||||
|
"url": url,
|
||||||
|
"statusCode": status_code,
|
||||||
|
"cacheMaxAge": cache_value(&root, "cacheMaxAge"),
|
||||||
|
"staleRevalidate": cache_value(&root, "staleRevalidate"),
|
||||||
|
"staleError": cache_value(&root, "staleError"),
|
||||||
|
"valueJson": payload.to_string()
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalize_addon_subtitles_json(subtitles_json: &str, resource_url: &str) -> String {
|
||||||
|
let subtitles = serde_json::from_str::<Vec<Value>>(subtitles_json)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|mut subtitle| {
|
||||||
|
let object = subtitle.as_object_mut()?;
|
||||||
|
let explicit_url = object
|
||||||
|
.get("url")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(str::to_string);
|
||||||
|
let attribute_url = object
|
||||||
|
.get("attributes")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|attributes| attributes.get("url"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(str::to_string);
|
||||||
|
let resolved_url =
|
||||||
|
resolve_resource_asset_url(explicit_url.or(attribute_url), resource_url)?;
|
||||||
|
|
||||||
|
object.insert("url".to_string(), Value::String(resolved_url.clone()));
|
||||||
|
let lang = object
|
||||||
|
.get("lang")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(str::to_string);
|
||||||
|
let attributes = object
|
||||||
|
.entry("attributes".to_string())
|
||||||
|
.or_insert_with(|| json!({}));
|
||||||
|
if !attributes.is_object() {
|
||||||
|
*attributes = json!({});
|
||||||
|
}
|
||||||
|
let attributes = attributes.as_object_mut()?;
|
||||||
|
let attribute_url_is_blank = attributes
|
||||||
|
.get("url")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.is_empty();
|
||||||
|
if attribute_url_is_blank {
|
||||||
|
attributes.insert("url".to_string(), Value::String(resolved_url));
|
||||||
|
}
|
||||||
|
let languages_empty = attributes
|
||||||
|
.get("languages")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(Vec::is_empty)
|
||||||
|
.unwrap_or(true);
|
||||||
|
if languages_empty {
|
||||||
|
if let Some(lang) = lang {
|
||||||
|
attributes.insert("languages".to_string(), json!([lang]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(subtitle)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Value::Array(subtitles).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_resource_asset_url(asset: Option<String>, resource_url: &str) -> Option<String> {
|
||||||
|
let secure = addon_protocol::prefer_https_asset_url(asset?.as_str())?;
|
||||||
|
if addon_protocol::is_http_url(&secure) {
|
||||||
|
return Some(secure);
|
||||||
|
}
|
||||||
|
if secure.starts_with('/') {
|
||||||
|
let scheme_end = resource_url.find("://").map(|index| index + 3)?;
|
||||||
|
let host_end = resource_url[scheme_end..]
|
||||||
|
.find('/')
|
||||||
|
.map(|index| scheme_end + index)
|
||||||
|
.unwrap_or(resource_url.len());
|
||||||
|
return addon_protocol::prefer_https_asset_url(&format!(
|
||||||
|
"{}{}",
|
||||||
|
&resource_url[..host_end],
|
||||||
|
secure
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let base = resource_url
|
||||||
|
.rsplit_once('/')
|
||||||
|
.map(|(base, _)| format!("{base}/"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
addon_protocol::prefer_https_asset_url(&format!("{base}{secure}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{normalize_addon_subtitles_json, parse_addon_resource_result_json};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_payload_keeps_provider_order_and_content() {
|
||||||
|
let result = parse_addon_resource_result_json(
|
||||||
|
"stream",
|
||||||
|
"https://addon.example/stream/movie/tt1.json",
|
||||||
|
200,
|
||||||
|
Some(
|
||||||
|
r#"{"streams":[{"title":"B"},{"title":"A"}],"cacheMaxAge":3600,"staleRevalidate":120,"staleError":60}"#,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let result: Value = serde_json::from_str(&result).expect("result json");
|
||||||
|
let value_json = result
|
||||||
|
.get("valueJson")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.expect("value json");
|
||||||
|
let streams: Value = serde_json::from_str(value_json).expect("streams");
|
||||||
|
|
||||||
|
assert_eq!(result.get("kind").and_then(Value::as_str), Some("success"));
|
||||||
|
assert_eq!(
|
||||||
|
streams
|
||||||
|
.get(0)
|
||||||
|
.and_then(|item| item.get("title"))
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("B")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
streams
|
||||||
|
.get(1)
|
||||||
|
.and_then(|item| item.get("title"))
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("A")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.get("cacheMaxAge").and_then(Value::as_i64),
|
||||||
|
Some(3600)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.get("staleRevalidate").and_then(Value::as_i64),
|
||||||
|
Some(120)
|
||||||
|
);
|
||||||
|
assert_eq!(result.get("staleError").and_then(Value::as_i64), Some(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_and_error_states_are_classified_without_platform_code() {
|
||||||
|
let empty =
|
||||||
|
parse_addon_resource_result_json("catalog", "url", 200, Some(r#"{"metas":[]}"#));
|
||||||
|
let network = parse_addon_resource_result_json("catalog", "url", 503, Some("{}"));
|
||||||
|
let parse = parse_addon_resource_result_json("catalog", "url", 200, Some("{"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&empty)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.get("kind").and_then(Value::as_str).map(str::to_owned))
|
||||||
|
.as_deref(),
|
||||||
|
Some("empty")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&network)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.get("kind").and_then(Value::as_str).map(str::to_owned))
|
||||||
|
.as_deref(),
|
||||||
|
Some("network_error")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Value>(&parse)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.get("kind").and_then(Value::as_str).map(str::to_owned))
|
||||||
|
.as_deref(),
|
||||||
|
Some("parse_error")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subtitle_payload_is_resolved_without_reordering_valid_entries() {
|
||||||
|
let subtitles = normalize_addon_subtitles_json(
|
||||||
|
r#"[
|
||||||
|
{"id":"one","url":"/subs/one.vtt","lang":"en","attributes":{"languages":[]}},
|
||||||
|
{"id":"drop","attributes":{}},
|
||||||
|
{"id":"two","attributes":{"url":"two.srt"}}
|
||||||
|
]"#,
|
||||||
|
"https://addon.example/subtitles/movie/tt1.json",
|
||||||
|
);
|
||||||
|
let subtitles: Value = serde_json::from_str(&subtitles).expect("subtitles");
|
||||||
|
|
||||||
|
assert_eq!(subtitles.as_array().map(Vec::len), Some(2));
|
||||||
|
assert_eq!(subtitles[0]["id"].as_str(), Some("one"));
|
||||||
|
assert_eq!(
|
||||||
|
subtitles[0]["url"].as_str(),
|
||||||
|
Some("https://addon.example/subs/one.vtt")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
subtitles[0]["attributes"]["languages"][0].as_str(),
|
||||||
|
Some("en")
|
||||||
|
);
|
||||||
|
assert_eq!(subtitles[1]["id"].as_str(), Some("two"));
|
||||||
|
assert_eq!(
|
||||||
|
subtitles[1]["url"].as_str(),
|
||||||
|
Some("https://addon.example/subtitles/movie/two.srt")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
499
src/addon_store.rs
Normal file
499
src/addon_store.rs
Normal file
|
|
@ -0,0 +1,499 @@
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SearchPolicyRequest {
|
||||||
|
query: String,
|
||||||
|
#[serde(rename = "nowMillis")]
|
||||||
|
now_millis: i64,
|
||||||
|
#[serde(rename = "cachedAtMillis")]
|
||||||
|
cached_at_millis: Option<i64>,
|
||||||
|
#[serde(rename = "ttlMillis")]
|
||||||
|
ttl_millis: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn addon_store_input_type(raw: &str) -> &'static str {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
let lower = trimmed.to_ascii_lowercase();
|
||||||
|
if lower.contains("manifest.json") {
|
||||||
|
return "stremio_manifest";
|
||||||
|
}
|
||||||
|
if lower.starts_with("cloudstreamrepo://")
|
||||||
|
|| lower.starts_with("cloudstream://")
|
||||||
|
|| lower.contains("cloudstream")
|
||||||
|
|| lower.contains(".cs3")
|
||||||
|
|| lower.contains("repo.json")
|
||||||
|
|| ((lower.starts_with("http://") || lower.starts_with("https://")) && trimmed.len() > 20)
|
||||||
|
{
|
||||||
|
return "cloudstream_repo";
|
||||||
|
}
|
||||||
|
|
||||||
|
"search_query"
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalize_cloudstream_repo_url(raw: &str) -> String {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
replace_ascii_prefix(
|
||||||
|
&replace_ascii_prefix(trimmed, "cloudstreamrepo://", "https://"),
|
||||||
|
"cloudstream://",
|
||||||
|
"https://",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalize_plugin_repository_url(raw: &str) -> String {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
let Some(scheme_end) = trimmed.find("://") else {
|
||||||
|
return trimmed.to_string();
|
||||||
|
};
|
||||||
|
let scheme = trimmed[..scheme_end].to_ascii_lowercase();
|
||||||
|
if scheme != "http" && scheme != "https" {
|
||||||
|
return format!("https://{}", &trimmed[scheme_end + 3..]);
|
||||||
|
}
|
||||||
|
replace_ascii_prefix(trimmed, "http://", "https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_secure_remote_url(raw: &str) -> bool {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
let lower = trimmed.to_ascii_lowercase();
|
||||||
|
if !lower.starts_with("https://") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let host = trimmed["https://".len()..]
|
||||||
|
.split(['/', '?', '#'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim();
|
||||||
|
!host.is_empty() && !host.contains(char::is_whitespace)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn same_plugin_repository_url(left: &str, right: &str) -> bool {
|
||||||
|
canonical_url_for_compare(left) == canonical_url_for_compare(right)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_url_for_compare(raw: &str) -> String {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
let lower = trimmed.to_ascii_lowercase();
|
||||||
|
if let Some(rest) = lower.strip_prefix("https://") {
|
||||||
|
format!("https://{}", rest.trim_end_matches('/'))
|
||||||
|
} else if let Some(rest) = lower.strip_prefix("http://") {
|
||||||
|
format!("https://{}", rest.trim_end_matches('/'))
|
||||||
|
} else {
|
||||||
|
lower.trim_end_matches('/').to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn profile_local_addons_key_json(profile_json: &str) -> Option<String> {
|
||||||
|
let profile: Value = serde_json::from_str(profile_json).ok()?;
|
||||||
|
let id = string_field(&profile, "id").unwrap_or_default();
|
||||||
|
let email = string_field(&profile, "email").unwrap_or_default();
|
||||||
|
Some(format!(
|
||||||
|
"local_addons_{}",
|
||||||
|
if id.trim().is_empty() { email } else { id }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sanitize_profile_json(
|
||||||
|
profile_json: &str,
|
||||||
|
mirrored_addons_json: &str,
|
||||||
|
merge_mirrored_addons: bool,
|
||||||
|
) -> Option<String> {
|
||||||
|
let mut profile: Value = serde_json::from_str(profile_json).ok()?;
|
||||||
|
let mirrored_addons: Vec<String> = if merge_mirrored_addons {
|
||||||
|
serde_json::from_str(mirrored_addons_json).unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut base_addons = string_list_field(&profile, "localAddons");
|
||||||
|
base_addons.extend(mirrored_addons);
|
||||||
|
let cleaned_addons = normalize_distinct_addons(base_addons);
|
||||||
|
let cleaned_ids = cleaned_addons
|
||||||
|
.iter()
|
||||||
|
.map(|addon| crate::addon_protocol::identity(addon))
|
||||||
|
.collect::<std::collections::HashSet<_>>();
|
||||||
|
let cleaned_disabled_addons =
|
||||||
|
normalize_distinct_addons(string_list_field(&profile, "disabledLocalAddons"))
|
||||||
|
.into_iter()
|
||||||
|
.filter(|addon| cleaned_ids.contains(&crate::addon_protocol::identity(addon)))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let object = profile.as_object_mut()?;
|
||||||
|
object.insert("localAddons".to_string(), json!(cleaned_addons));
|
||||||
|
object.insert(
|
||||||
|
"disabledLocalAddons".to_string(),
|
||||||
|
json!(cleaned_disabled_addons),
|
||||||
|
);
|
||||||
|
fill_structured_settings(object);
|
||||||
|
serde_json::to_string(&profile).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_distinct_addons(addons: Vec<String>) -> Vec<String> {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
addons
|
||||||
|
.into_iter()
|
||||||
|
.map(|addon| crate::addon_protocol::normalize_manifest_url(&addon))
|
||||||
|
.filter(|addon| !addon.trim().is_empty())
|
||||||
|
.filter(|addon| seen.insert(crate::addon_protocol::identity(addon)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_structured_settings(profile: &mut Map<String, Value>) {
|
||||||
|
insert_object_from_fields(
|
||||||
|
profile,
|
||||||
|
"externalAccounts",
|
||||||
|
&[
|
||||||
|
("traktAccessToken", "traktAccessToken"),
|
||||||
|
("traktRefreshToken", "traktRefreshToken"),
|
||||||
|
("traktTokenExpiresAt", "traktTokenExpiresAt"),
|
||||||
|
("traktLastSyncAt", "traktLastSyncAt"),
|
||||||
|
("traktLastSyncedItems", "traktLastSyncedItems"),
|
||||||
|
(
|
||||||
|
"traktLastContinueWatchingCount",
|
||||||
|
"traktLastContinueWatchingCount",
|
||||||
|
),
|
||||||
|
("traktLastWatchlistCount", "traktLastWatchlistCount"),
|
||||||
|
("malAccessToken", "malAccessToken"),
|
||||||
|
("malRefreshToken", "malRefreshToken"),
|
||||||
|
("simklAccessToken", "simklAccessToken"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
insert_object_from_fields(
|
||||||
|
profile,
|
||||||
|
"addonSettings",
|
||||||
|
&[
|
||||||
|
("localAddons", "localAddons"),
|
||||||
|
("disabledLocalAddons", "disabledLocalAddons"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
insert_object_from_fields(
|
||||||
|
profile,
|
||||||
|
"subtitleSettings",
|
||||||
|
&[
|
||||||
|
("size", "subtitleSize"),
|
||||||
|
("color", "subtitleColor"),
|
||||||
|
("backgroundColor", "subtitleBackgroundColor"),
|
||||||
|
("outlineColor", "subtitleOutlineColor"),
|
||||||
|
("textOpacity", "subtitleTextOpacity"),
|
||||||
|
("backgroundOpacity", "subtitleBackgroundOpacity"),
|
||||||
|
("outlineOpacity", "subtitleOutlineOpacity"),
|
||||||
|
("preferredLanguage", "preferredSubtitleLanguage"),
|
||||||
|
("secondaryLanguage", "secondarySubtitleLanguage"),
|
||||||
|
("shadow", "subtitleShadow"),
|
||||||
|
("autoEnable", "autoEnableSubtitles"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
insert_object_from_fields(
|
||||||
|
profile,
|
||||||
|
"playbackSettings",
|
||||||
|
&[
|
||||||
|
("preferredAudioLanguage", "preferredAudioLanguage"),
|
||||||
|
("secondaryAudioLanguage", "secondaryAudioLanguage"),
|
||||||
|
("stableVolume", "stableVolume"),
|
||||||
|
("ambientLight", "ambientLight"),
|
||||||
|
("forceSoftwareAudio", "forceSoftwareAudio"),
|
||||||
|
("preferredPlayer", "preferredPlayer"),
|
||||||
|
("autoSkipIntro", "autoSkipIntro"),
|
||||||
|
("autoPlayNextEpisode", "autoPlayNextEpisode"),
|
||||||
|
("nextEpisodeThresholdPercent", "nextEpisodeThresholdPercent"),
|
||||||
|
("watchedThresholdPercent", "watchedThresholdPercent"),
|
||||||
|
("seekForwardSeconds", "seekForwardSeconds"),
|
||||||
|
("seekBackwardSeconds", "seekBackwardSeconds"),
|
||||||
|
("playerBufferCacheMb", "playerBufferCacheMb"),
|
||||||
|
("playerForwardBufferSeconds", "playerForwardBufferSeconds"),
|
||||||
|
("playerBackBufferSeconds", "playerBackBufferSeconds"),
|
||||||
|
("backgroundPlayback", "backgroundPlayback"),
|
||||||
|
("pictureInPicture", "pictureInPicture"),
|
||||||
|
("playbackSpeed", "playbackSpeed"),
|
||||||
|
("holdToSpeedEnabled", "holdToSpeedEnabled"),
|
||||||
|
("holdSpeed", "holdSpeed"),
|
||||||
|
("dolbyVisionFallbackMode", "dolbyVisionFallbackMode"),
|
||||||
|
("dv7Fallback", "dv7Fallback"),
|
||||||
|
("dv7ToDv8Fallback", "dv7ToDv8Fallback"),
|
||||||
|
("tunneledPlayback", "tunneledPlayback"),
|
||||||
|
("useIntroDb", "useIntroDb"),
|
||||||
|
("useAniSkip", "useAniSkip"),
|
||||||
|
("defaultQuality", "defaultQuality"),
|
||||||
|
("mobileDataUsage", "mobileDataUsage"),
|
||||||
|
("hdrPlayback", "hdrPlayback"),
|
||||||
|
("resumePlayback", "resumePlayback"),
|
||||||
|
("autoplayMode", "autoplayMode"),
|
||||||
|
("streamSourceSelectionMode", "streamSourceSelectionMode"),
|
||||||
|
("streamSourceRegexPattern", "streamSourceRegexPattern"),
|
||||||
|
("tryBingeGroup", "tryBingeGroup"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
insert_object_from_fields(
|
||||||
|
profile,
|
||||||
|
"torrentSettings",
|
||||||
|
&[
|
||||||
|
("wifiOnly", "torrentWifiOnly"),
|
||||||
|
("maxConnections", "torrentMaxConnections"),
|
||||||
|
("speedPreset", "torrentSpeedPreset"),
|
||||||
|
("cachePreset", "torrentCachePreset"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
insert_object_from_fields(
|
||||||
|
profile,
|
||||||
|
"appearanceSettings",
|
||||||
|
&[
|
||||||
|
("language", "language"),
|
||||||
|
("cardLayout", "cardLayout"),
|
||||||
|
("continueWatchingLayout", "continueWatchingLayout"),
|
||||||
|
("continueWatchingArtwork", "continueWatchingArtwork"),
|
||||||
|
("continueWatchingEnabled", "continueWatchingEnabled"),
|
||||||
|
("appTheme", "appTheme"),
|
||||||
|
("accentColorArgb", "accentColorArgb"),
|
||||||
|
("cardCornerPreset", "cardCornerPreset"),
|
||||||
|
("interfaceDensity", "interfaceDensity"),
|
||||||
|
("amoledMode", "amoledMode"),
|
||||||
|
("posterWidthPreset", "posterWidthPreset"),
|
||||||
|
("posterLandscapeMode", "posterLandscapeMode"),
|
||||||
|
("posterHideTitles", "posterHideTitles"),
|
||||||
|
("detailEpisodeViewMode", "detailEpisodeViewMode"),
|
||||||
|
("detailSeasonSelectorMode", "detailSeasonSelectorMode"),
|
||||||
|
("detailSeasonPostersOnHero", "detailSeasonPostersOnHero"),
|
||||||
|
("homeSeasonPostersOnHero", "homeSeasonPostersOnHero"),
|
||||||
|
("animationsEnabled", "animationsEnabled"),
|
||||||
|
("reduceMotion", "reduceMotion"),
|
||||||
|
("startPage", "startPage"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
insert_object_from_fields(
|
||||||
|
profile,
|
||||||
|
"homeFeedSettings",
|
||||||
|
&[
|
||||||
|
("heroFeedToggles", "heroFeedToggles"),
|
||||||
|
("homeFeedToggles", "homeFeedToggles"),
|
||||||
|
("topTenFeedToggles", "topTenFeedToggles"),
|
||||||
|
("heroFeedOrder", "heroFeedOrder"),
|
||||||
|
("homeFeedOrder", "homeFeedOrder"),
|
||||||
|
("showHeroSection", "showHeroSection"),
|
||||||
|
("libraryCollections", "libraryCollections"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_object_from_fields(
|
||||||
|
profile: &mut Map<String, Value>,
|
||||||
|
target: &str,
|
||||||
|
fields: &[(&str, &str)],
|
||||||
|
) {
|
||||||
|
let updates = fields
|
||||||
|
.iter()
|
||||||
|
.map(|(target_key, source_key)| {
|
||||||
|
(
|
||||||
|
(*target_key).to_string(),
|
||||||
|
profile.get(*source_key).cloned().unwrap_or(Value::Null),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if profile.get(target).is_some_and(|value| !value.is_null()) {
|
||||||
|
if let Some(target_object) = profile.get_mut(target).and_then(Value::as_object_mut) {
|
||||||
|
for (key, value) in updates {
|
||||||
|
target_object.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let object = updates.into_iter().collect::<Map<_, _>>();
|
||||||
|
profile.insert(target.to_string(), Value::Object(object));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_field(value: &Value, key: &str) -> Option<String> {
|
||||||
|
value.get(key).and_then(Value::as_str).map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_list_field(value: &Value, key: &str) -> Vec<String> {
|
||||||
|
value
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn addon_store_search_policy_json(request_json: &str) -> Option<String> {
|
||||||
|
let request: SearchPolicyRequest = serde_json::from_str(request_json).ok()?;
|
||||||
|
let normalized_query = request.query.trim().to_ascii_lowercase();
|
||||||
|
if normalized_query.len() < 2 {
|
||||||
|
return serde_json::to_string(&json!({
|
||||||
|
"normalizedQuery": normalized_query,
|
||||||
|
"url": "",
|
||||||
|
"useCache": false,
|
||||||
|
"shouldFetch": false
|
||||||
|
}))
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
let use_cache = request
|
||||||
|
.cached_at_millis
|
||||||
|
.and_then(|cached_at| request.now_millis.checked_sub(cached_at))
|
||||||
|
.map(|elapsed| elapsed <= request.ttl_millis)
|
||||||
|
.unwrap_or(false);
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"normalizedQuery": normalized_query,
|
||||||
|
"url": format!(
|
||||||
|
"https://stremio-addons.net/addons?query={}",
|
||||||
|
form_urlencode(&normalized_query)
|
||||||
|
),
|
||||||
|
"useCache": use_cache,
|
||||||
|
"shouldFetch": !use_cache
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_url_regex() -> &'static Regex {
|
||||||
|
static REGEX: OnceLock<Regex> = OnceLock::new();
|
||||||
|
REGEX.get_or_init(|| {
|
||||||
|
Regex::new(r#"https?://[^"'\\ ]+manifest\.json[^"'\\ ]*"#).expect("valid manifest url regex")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn extract_addon_manifest_url(detail_text: &str) -> Option<String> {
|
||||||
|
let unescaped_text = detail_text.replace("\\/", "/").replace("\\u0026", "&");
|
||||||
|
manifest_url_regex()
|
||||||
|
.find(&unescaped_text)
|
||||||
|
.map(|match_| match_.as_str().trim_end_matches('\\').to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_ascii_prefix(value: &str, prefix: &str, replacement: &str) -> String {
|
||||||
|
if value.len() >= prefix.len() && value[..prefix.len()].eq_ignore_ascii_case(prefix) {
|
||||||
|
format!("{replacement}{}", &value[prefix.len()..])
|
||||||
|
} else {
|
||||||
|
value.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn form_urlencode(value: &str) -> String {
|
||||||
|
let mut encoded = String::with_capacity(value.len());
|
||||||
|
for byte in value.bytes() {
|
||||||
|
let keep = byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'*');
|
||||||
|
if keep {
|
||||||
|
encoded.push(byte as char);
|
||||||
|
} else if byte == b' ' {
|
||||||
|
encoded.push('+');
|
||||||
|
} else {
|
||||||
|
encoded.push_str(&format!("%{byte:02X}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_manifest_before_generic_https_repo_rule() {
|
||||||
|
assert_eq!(
|
||||||
|
"stremio_manifest",
|
||||||
|
addon_store_input_type("https://addon.example/manifest.json")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
"cloudstream_repo",
|
||||||
|
addon_store_input_type("cloudstreamrepo://example.com/repo.json")
|
||||||
|
);
|
||||||
|
assert_eq!("search_query", addon_store_input_type("cinemeta"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plans_search_url_and_cache_use() {
|
||||||
|
let json = addon_store_search_policy_json(
|
||||||
|
r#"{"query":"Game of Thrones","nowMillis":2000,"cachedAtMillis":1500,"ttlMillis":1000}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(json.contains(r#""normalizedQuery":"game of thrones""#));
|
||||||
|
assert!(json.contains(r#""url":"https://stremio-addons.net/addons?query=game+of+thrones""#));
|
||||||
|
assert!(json.contains(r#""useCache":true"#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_escaped_manifest_url_from_detail_page() {
|
||||||
|
assert_eq!(
|
||||||
|
Some("https://addon.example/root/manifest.json?x=1&y=2".to_string()),
|
||||||
|
extract_addon_manifest_url(
|
||||||
|
r#"<script>"https://addon.example\/root\/manifest.json?x=1\u0026y=2"</script>"#,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_repository_url_policy_normalizes_and_requires_https() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_plugin_repository_url("cloudstream://example.com/repo.json"),
|
||||||
|
"https://example.com/repo.json"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_plugin_repository_url("http://example.com/repo.json"),
|
||||||
|
"https://example.com/repo.json"
|
||||||
|
);
|
||||||
|
assert!(is_secure_remote_url("https://example.com/repo.json"));
|
||||||
|
assert!(!is_secure_remote_url("http://example.com/repo.json"));
|
||||||
|
assert!(same_plugin_repository_url(
|
||||||
|
"http://example.com/repo.json/",
|
||||||
|
"https://EXAMPLE.com/repo.json"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_profile_merges_and_deduplicates_local_addons() {
|
||||||
|
let sanitized = sanitize_profile_json(
|
||||||
|
r#"{"id":"p1","email":"u@example.com","localAddons":["http://a.example/manifest.json"],"disabledLocalAddons":["https://b.example/manifest.json","https://missing.example/manifest.json"],"language":"tr"}"#,
|
||||||
|
r#"["https://a.example/manifest.json","https://b.example/manifest.json"]"#,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.expect("profile");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sanitized
|
||||||
|
.get("localAddons")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(Vec::len),
|
||||||
|
Some(2)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sanitized
|
||||||
|
.get("disabledLocalAddons")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(Vec::len),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sanitized
|
||||||
|
.get("appearanceSettings")
|
||||||
|
.and_then(|value| value.get("language"))
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("tr")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_profile_syncs_home_feed_settings_from_top_level_fields() {
|
||||||
|
let sanitized = sanitize_profile_json(
|
||||||
|
r#"{"id":"p1","email":"u@example.com","localAddons":["https://a.example/manifest.json"],"libraryCollections":[{"id":"new","title":"New"}],"homeFeedSettings":{"libraryCollections":[{"id":"old","title":"Old"}],"homeFeedToggles":["old"]},"homeFeedToggles":[]}"#,
|
||||||
|
r#"[]"#,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.expect("profile");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sanitized["homeFeedSettings"]["libraryCollections"][0]["id"],
|
||||||
|
"new"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sanitized["homeFeedSettings"]["homeFeedToggles"]
|
||||||
|
.as_array()
|
||||||
|
.map(Vec::len),
|
||||||
|
Some(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/addon_transport/mod.rs
Normal file
15
src/addon_transport/mod.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
use crate::types::addon::AddonManifest;
|
||||||
|
|
||||||
|
/// Mirrors stremio-core's `AddonTransport` trait.
|
||||||
|
/// Rust should never implement this by doing HTTP directly — the platform (Kotlin/JS) executes HTTP and passes results back via completeEffect.
|
||||||
|
pub trait AddonTransport {
|
||||||
|
fn manifest(&self) -> Option<&AddonManifest>;
|
||||||
|
|
||||||
|
fn resource(
|
||||||
|
&self,
|
||||||
|
resource: &str,
|
||||||
|
type_: &str,
|
||||||
|
id: &str,
|
||||||
|
extra: Option<&str>,
|
||||||
|
) -> Result<String, String>;
|
||||||
|
}
|
||||||
493
src/app_state.rs
Normal file
493
src/app_state.rs
Normal file
|
|
@ -0,0 +1,493 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AppCoreState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub home: HomeState,
|
||||||
|
#[serde(default)]
|
||||||
|
pub home_search: HomeSearchState,
|
||||||
|
#[serde(default)]
|
||||||
|
pub billboard: BillboardState,
|
||||||
|
#[serde(default)]
|
||||||
|
pub discover: DiscoverState,
|
||||||
|
#[serde(default)]
|
||||||
|
pub calendar: CalendarState,
|
||||||
|
#[serde(default)]
|
||||||
|
pub library: LibraryState,
|
||||||
|
#[serde(default)]
|
||||||
|
pub player: PlayerCoreState,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: HashMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BillboardState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub pool: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub index: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub movie: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub logo: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub watchlist: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub next_episode: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub trailer_url: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BillboardState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
error: Value::Null,
|
||||||
|
pool: json!([]),
|
||||||
|
index: 0,
|
||||||
|
movie: Value::Null,
|
||||||
|
logo: Value::Null,
|
||||||
|
watchlist: false,
|
||||||
|
next_episode: Value::Null,
|
||||||
|
trailer_url: Value::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DiscoverState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub results: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_loading: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub genres: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub catalogs: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DiscoverState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
results: json!([]),
|
||||||
|
is_loading: false,
|
||||||
|
genres: json!([]),
|
||||||
|
catalogs: json!([]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CalendarState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub items: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_loading: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CalendarState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
items: json!([]),
|
||||||
|
is_loading: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct LibraryState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub ui_state: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LibraryState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
ui_state: json!({}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HomeState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub categories: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_loading: bool,
|
||||||
|
#[serde(default = "default_home_filter")]
|
||||||
|
pub current_filter: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_direct_loading: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub trakt_continue_watching_last_updated_at: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_addons: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub watchlist: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub liked_items: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub active_profile: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub current_watchlist: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub external_continue_watching: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub trakt_watched_state: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HomeState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
categories: json!([]),
|
||||||
|
is_loading: false,
|
||||||
|
current_filter: default_home_filter(),
|
||||||
|
is_direct_loading: false,
|
||||||
|
trakt_continue_watching_last_updated_at: 0,
|
||||||
|
user_addons: json!([]),
|
||||||
|
watchlist: json!([]),
|
||||||
|
liked_items: json!([]),
|
||||||
|
active_profile: Value::Null,
|
||||||
|
current_watchlist: json!([]),
|
||||||
|
external_continue_watching: json!([]),
|
||||||
|
trakt_watched_state: json!({}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_home_filter() -> String {
|
||||||
|
"all".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HomeSearchState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub search_results: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub search_rows: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub search_history: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub focused_movie: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub focused_movie_trailer_url: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub preview_url: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HomeSearchState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
search_results: json!([]),
|
||||||
|
search_rows: json!([]),
|
||||||
|
search_history: json!([]),
|
||||||
|
focused_movie: Value::Null,
|
||||||
|
focused_movie_trailer_url: Value::Null,
|
||||||
|
preview_url: Value::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PlayerCoreState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub current_video_id: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub current_stream_index: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_saved_position: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub should_apply_initial_progress: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub playback_ended: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_started_playing: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_video_rendered: bool,
|
||||||
|
#[serde(default = "default_buffering")]
|
||||||
|
pub is_buffering: bool,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: HashMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PlayerCoreState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
current_video_id: Value::Null,
|
||||||
|
current_stream_index: 0,
|
||||||
|
last_saved_position: 0,
|
||||||
|
should_apply_initial_progress: false,
|
||||||
|
playback_ended: false,
|
||||||
|
has_started_playing: false,
|
||||||
|
is_video_rendered: false,
|
||||||
|
is_buffering: true,
|
||||||
|
extra: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_buffering() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct AppCoreAction {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
action_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
value: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
video_id: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1);
|
||||||
|
static STORE: OnceLock<Mutex<HashMap<u64, AppCoreState>>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn store() -> &'static Mutex<HashMap<u64, AppCoreState>> {
|
||||||
|
STORE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_app_core_state(initial_json: &str) -> u64 {
|
||||||
|
let state = serde_json::from_str(initial_json).unwrap_or_default();
|
||||||
|
if let Ok(mut states) = store().lock() {
|
||||||
|
let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
|
||||||
|
states.insert(handle, state);
|
||||||
|
handle
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn destroy_app_core_state(handle: u64) -> bool {
|
||||||
|
store()
|
||||||
|
.lock()
|
||||||
|
.map(|mut states| states.remove(&handle).is_some())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn app_core_state_json(handle: u64) -> Option<String> {
|
||||||
|
store().lock().ok().and_then(|states| {
|
||||||
|
states
|
||||||
|
.get(&handle)
|
||||||
|
.and_then(|state| serde_json::to_string(state).ok())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn app_core_dispatch_json(handle: u64, action_json: &str) -> Option<String> {
|
||||||
|
let action: AppCoreAction = serde_json::from_str(action_json).ok()?;
|
||||||
|
let mut states = store().lock().ok()?;
|
||||||
|
let state = states.get_mut(&handle)?;
|
||||||
|
reduce(state, action);
|
||||||
|
serde_json::to_string(state).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reduce(state: &mut AppCoreState, action: AppCoreAction) {
|
||||||
|
match action.action_type.as_str() {
|
||||||
|
"setHomeCategories" => state.home.categories = array_or_empty(action.value),
|
||||||
|
"setHomeLoading" => state.home.is_loading = action.value.as_bool().unwrap_or(false),
|
||||||
|
"setHomeCurrentFilter" => {
|
||||||
|
state.home.current_filter = action
|
||||||
|
.value
|
||||||
|
.as_str()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("all")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
"setHomeDirectLoading" => {
|
||||||
|
state.home.is_direct_loading = action.value.as_bool().unwrap_or(false)
|
||||||
|
}
|
||||||
|
"setTraktContinueWatchingLastUpdatedAt" => {
|
||||||
|
state.home.trakt_continue_watching_last_updated_at = action.value.as_i64().unwrap_or(0)
|
||||||
|
}
|
||||||
|
"setUserAddons" => state.home.user_addons = array_or_empty(action.value),
|
||||||
|
"setWatchlist" => state.home.watchlist = array_or_empty(action.value),
|
||||||
|
"setLikedItems" => state.home.liked_items = array_or_empty(action.value),
|
||||||
|
"setActiveProfile" => state.home.active_profile = action.value,
|
||||||
|
"setCurrentWatchlist" => state.home.current_watchlist = array_or_empty(action.value),
|
||||||
|
"setExternalContinueWatching" => {
|
||||||
|
state.home.external_continue_watching = array_or_empty(action.value)
|
||||||
|
}
|
||||||
|
"setTraktWatchedState" => state.home.trakt_watched_state = action.value,
|
||||||
|
"setSearchResults" => state.home_search.search_results = array_or_empty(action.value),
|
||||||
|
"setSearchRows" => state.home_search.search_rows = array_or_empty(action.value),
|
||||||
|
"setSearchHistory" => state.home_search.search_history = array_or_empty(action.value),
|
||||||
|
"setFocusedMovie" => state.home_search.focused_movie = action.value,
|
||||||
|
"setFocusedMovieTrailerUrl" => state.home_search.focused_movie_trailer_url = action.value,
|
||||||
|
"setPreviewUrl" => state.home_search.preview_url = action.value,
|
||||||
|
"setBillboardError" => state.billboard.error = action.value,
|
||||||
|
"setBillboardPool" => state.billboard.pool = array_or_empty(action.value),
|
||||||
|
"setBillboardIndex" => state.billboard.index = action.value.as_i64().unwrap_or(0).max(0),
|
||||||
|
"setBillboardMovie" => state.billboard.movie = action.value,
|
||||||
|
"setBillboardLogo" => state.billboard.logo = action.value,
|
||||||
|
"setBillboardWatchlist" => {
|
||||||
|
state.billboard.watchlist = action.value.as_bool().unwrap_or(false)
|
||||||
|
}
|
||||||
|
"setBillboardNextEpisode" => state.billboard.next_episode = action.value,
|
||||||
|
"setBillboardTrailerUrl" => state.billboard.trailer_url = action.value,
|
||||||
|
"setDiscoverResults" => state.discover.results = array_or_empty(action.value),
|
||||||
|
"setDiscoverLoading" => state.discover.is_loading = action.value.as_bool().unwrap_or(false),
|
||||||
|
"setDiscoverGenres" => state.discover.genres = array_or_empty(action.value),
|
||||||
|
"setDiscoverCatalogs" => state.discover.catalogs = array_or_empty(action.value),
|
||||||
|
"setCalendarItems" => state.calendar.items = array_or_empty(action.value),
|
||||||
|
"setCalendarLoading" => state.calendar.is_loading = action.value.as_bool().unwrap_or(false),
|
||||||
|
"setLibraryUiState" => state.library.ui_state = action.value,
|
||||||
|
"playerResetForEpisode" => reset_player_for_episode(&mut state.player, action.video_id),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn array_or_empty(value: Value) -> Value {
|
||||||
|
if value.is_array() {
|
||||||
|
value
|
||||||
|
} else {
|
||||||
|
json!([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_player_for_episode(player: &mut PlayerCoreState, video_id: Value) {
|
||||||
|
player.current_video_id = video_id;
|
||||||
|
player.current_stream_index = 0;
|
||||||
|
player.last_saved_position = 0;
|
||||||
|
player.should_apply_initial_progress = false;
|
||||||
|
player.playback_ended = false;
|
||||||
|
player.has_started_playing = false;
|
||||||
|
player.is_video_rendered = false;
|
||||||
|
player.is_buffering = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reducer_updates_home_search_state_without_reordering_payloads() {
|
||||||
|
let handle = create_app_core_state(
|
||||||
|
r#"{"home":{"categories":[{"id":"c1"}]},"homeSearch":{"searchHistory":[{"id":"tt1"}]}}"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let snapshot = app_core_dispatch_json(
|
||||||
|
handle,
|
||||||
|
r#"{"type":"setSearchResults","value":[{"id":"tt2"},{"id":"tt1"}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let value: Value = serde_json::from_str(&snapshot).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
value["homeSearch"]["searchResults"],
|
||||||
|
json!([{"id":"tt2"},{"id":"tt1"}])
|
||||||
|
);
|
||||||
|
assert_eq!(value["home"]["categories"], json!([{"id":"c1"}]));
|
||||||
|
assert_eq!(value["homeSearch"]["searchHistory"], json!([{"id":"tt1"}]));
|
||||||
|
assert!(destroy_app_core_state(handle));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reducer_owns_home_shell_state() {
|
||||||
|
let handle = create_app_core_state("{}");
|
||||||
|
|
||||||
|
let snapshot = app_core_dispatch_json(
|
||||||
|
handle,
|
||||||
|
r#"{"type":"setHomeCategories","value":[{"id":"continue_watching"},{"id":"popular"}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let value: Value = serde_json::from_str(&snapshot).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
value["home"]["categories"],
|
||||||
|
json!([{"id":"continue_watching"},{"id":"popular"}])
|
||||||
|
);
|
||||||
|
|
||||||
|
let snapshot = app_core_dispatch_json(
|
||||||
|
handle,
|
||||||
|
r#"{"type":"setHomeCurrentFilter","value":"movies"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let value: Value = serde_json::from_str(&snapshot).unwrap();
|
||||||
|
assert_eq!(value["home"]["currentFilter"], json!("movies"));
|
||||||
|
|
||||||
|
let snapshot =
|
||||||
|
app_core_dispatch_json(handle, r#"{"type":"setHomeLoading","value":true}"#).unwrap();
|
||||||
|
let value: Value = serde_json::from_str(&snapshot).unwrap();
|
||||||
|
assert_eq!(value["home"]["isLoading"], json!(true));
|
||||||
|
assert_eq!(value["home"]["currentFilter"], json!("movies"));
|
||||||
|
assert!(destroy_app_core_state(handle));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reducer_owns_home_feature_state_branches() {
|
||||||
|
let handle = create_app_core_state("{}");
|
||||||
|
|
||||||
|
let snapshot =
|
||||||
|
app_core_dispatch_json(handle, r#"{"type":"setBillboardIndex","value":2}"#).unwrap();
|
||||||
|
let value: Value = serde_json::from_str(&snapshot).unwrap();
|
||||||
|
assert_eq!(value["billboard"]["index"], json!(2));
|
||||||
|
|
||||||
|
let snapshot = app_core_dispatch_json(
|
||||||
|
handle,
|
||||||
|
r#"{"type":"setDiscoverResults","value":[{"id":"tt1"},{"id":"tt2"}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let value: Value = serde_json::from_str(&snapshot).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
value["discover"]["results"],
|
||||||
|
json!([{"id":"tt1"},{"id":"tt2"}])
|
||||||
|
);
|
||||||
|
|
||||||
|
let snapshot = app_core_dispatch_json(
|
||||||
|
handle,
|
||||||
|
r#"{"type":"setCalendarItems","value":[{"title":"Episode"}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let value: Value = serde_json::from_str(&snapshot).unwrap();
|
||||||
|
assert_eq!(value["calendar"]["items"], json!([{"title":"Episode"}]));
|
||||||
|
|
||||||
|
let snapshot = app_core_dispatch_json(
|
||||||
|
handle,
|
||||||
|
r#"{"type":"setLibraryUiState","value":{"isLoading":false,"lastLoadedProfileKey":"profile"}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let value: Value = serde_json::from_str(&snapshot).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
value["library"]["uiState"]["lastLoadedProfileKey"],
|
||||||
|
json!("profile")
|
||||||
|
);
|
||||||
|
assert!(destroy_app_core_state(handle));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reducer_resets_player_episode_state_like_kotlin_state_holder() {
|
||||||
|
let handle = create_app_core_state(
|
||||||
|
r#"{"player":{"currentStreamIndex":3,"lastSavedPosition":9200,"playbackEnded":true,"hasStartedPlaying":true,"isVideoRendered":true,"isBuffering":false}}"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
let snapshot = app_core_dispatch_json(
|
||||||
|
handle,
|
||||||
|
r#"{"type":"playerResetForEpisode","videoId":"tt123:1:2"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let value: Value = serde_json::from_str(&snapshot).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["player"]["currentVideoId"], json!("tt123:1:2"));
|
||||||
|
assert_eq!(value["player"]["currentStreamIndex"], json!(0));
|
||||||
|
assert_eq!(value["player"]["lastSavedPosition"], json!(0));
|
||||||
|
assert_eq!(value["player"]["shouldApplyInitialProgress"], json!(false));
|
||||||
|
assert_eq!(value["player"]["playbackEnded"], json!(false));
|
||||||
|
assert_eq!(value["player"]["hasStartedPlaying"], json!(false));
|
||||||
|
assert_eq!(value["player"]["isVideoRendered"], json!(false));
|
||||||
|
assert_eq!(value["player"]["isBuffering"], json!(true));
|
||||||
|
assert!(destroy_app_core_state(handle));
|
||||||
|
}
|
||||||
|
}
|
||||||
1932
src/bindings/jni.rs
Normal file
1932
src/bindings/jni.rs
Normal file
File diff suppressed because it is too large
Load diff
3
src/bindings/mod.rs
Normal file
3
src/bindings/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
#[cfg(feature = "native")]
|
||||||
|
pub mod jni;
|
||||||
|
pub mod uniffi;
|
||||||
72
src/bindings/uniffi.rs
Normal file
72
src/bindings/uniffi.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
use crate::{app_state, core_contract, headless_engine};
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn fluxa_core_version() -> String {
|
||||||
|
env!("CARGO_PKG_VERSION").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn create_headless_engine_json(initial_json: String) -> i64 {
|
||||||
|
headless_engine::create_headless_engine(&initial_json) as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn destroy_headless_engine_json(handle: i64) -> bool {
|
||||||
|
handle > 0 && headless_engine::destroy_headless_engine(handle as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn headless_engine_snapshot_json(handle: i64) -> String {
|
||||||
|
if handle <= 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
headless_engine::headless_engine_snapshot_json(handle as u64).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn headless_engine_dispatch_json(handle: i64, action_json: String) -> String {
|
||||||
|
if handle <= 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
headless_engine::headless_engine_dispatch_json(handle as u64, &action_json).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn headless_engine_complete_effect_json(handle: i64, result_json: String) -> String {
|
||||||
|
if handle <= 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
headless_engine::headless_engine_complete_effect_json(handle as u64, &result_json)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn core_capabilities_json(portable: bool) -> String {
|
||||||
|
core_contract::core_capabilities_json(portable)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn create_app_core_state_json(initial_json: String) -> i64 {
|
||||||
|
app_state::create_app_core_state(&initial_json) as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn destroy_app_core_state_json(handle: i64) -> bool {
|
||||||
|
handle > 0 && app_state::destroy_app_core_state(handle as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn app_core_state_json(handle: i64) -> String {
|
||||||
|
if handle <= 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
app_state::app_core_state_json(handle as u64).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn app_core_dispatch_json(handle: i64, action_json: String) -> String {
|
||||||
|
if handle <= 0 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
app_state::app_core_dispatch_json(handle as u64, &action_json).unwrap_or_default()
|
||||||
|
}
|
||||||
399
src/calendar_plan.rs
Normal file
399
src/calendar_plan.rs
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct CalendarItemInput {
|
||||||
|
date_iso: String,
|
||||||
|
#[serde(default)]
|
||||||
|
meta_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
meta_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
subtitle: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
season_number: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
episode_number: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
episode_title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
artwork_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ContentPlanRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
items: Vec<CalendarItemInput>,
|
||||||
|
month_prefix: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SeasonCandidatesRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
seasons_count: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
last_video_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct WidgetRowsRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
items: Vec<Value>,
|
||||||
|
#[serde(default = "default_max_rows")]
|
||||||
|
max_rows: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_rows() -> usize {
|
||||||
|
4
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct NotificationContentRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
items: Vec<CalendarItemInput>,
|
||||||
|
today_iso: String,
|
||||||
|
#[serde(default)]
|
||||||
|
already_notified_keys: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
profile_id: Option<String>,
|
||||||
|
notifications_enabled: Option<bool>,
|
||||||
|
alert_new_episodes: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ReleaseDetectionRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
items: Vec<Value>,
|
||||||
|
today_iso: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn calendar_content_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<ContentPlanRequest>(request_json).ok()?;
|
||||||
|
let prefix = request.month_prefix.trim();
|
||||||
|
if prefix.is_empty() {
|
||||||
|
return serde_json::to_string(&json!([])).ok();
|
||||||
|
}
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut filtered: Vec<&CalendarItemInput> = request
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| {
|
||||||
|
item.date_iso.starts_with(prefix)
|
||||||
|
&& !item.meta_id.trim().is_empty()
|
||||||
|
&& seen.insert(format!(
|
||||||
|
"{}:{}:{}",
|
||||||
|
item.date_iso,
|
||||||
|
item.meta_id,
|
||||||
|
item.subtitle.as_deref().unwrap_or("")
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
filtered.sort_by(|a, b| {
|
||||||
|
a.date_iso
|
||||||
|
.cmp(&b.date_iso)
|
||||||
|
.then_with(|| a.title.cmp(&b.title))
|
||||||
|
});
|
||||||
|
let out: Vec<Value> = filtered
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
json!({
|
||||||
|
"dateIso": item.date_iso,
|
||||||
|
"metaId": item.meta_id,
|
||||||
|
"metaType": item.meta_type,
|
||||||
|
"title": item.title,
|
||||||
|
"subtitle": item.subtitle,
|
||||||
|
"seasonNumber": item.season_number,
|
||||||
|
"episodeNumber": item.episode_number,
|
||||||
|
"episodeTitle": item.episode_title,
|
||||||
|
"artworkUrl": item.artwork_url
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
serde_json::to_string(&out).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn calendar_season_candidates_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<SeasonCandidatesRequest>(request_json).ok()?;
|
||||||
|
let seasons_count = request.seasons_count.unwrap_or(1).max(1);
|
||||||
|
let watched_season = request
|
||||||
|
.last_video_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|id| id.split(':').nth(1))
|
||||||
|
.and_then(|s| s.parse::<i32>().ok());
|
||||||
|
let focused: Vec<i32> = [
|
||||||
|
watched_season,
|
||||||
|
watched_season.map(|s| s + 1),
|
||||||
|
Some(seasons_count),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|&s| s > 0 && s <= seasons_count)
|
||||||
|
.collect::<std::collections::BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
let full: Vec<i32> = if seasons_count <= 8 {
|
||||||
|
(1..=seasons_count).collect()
|
||||||
|
} else {
|
||||||
|
focused.clone()
|
||||||
|
};
|
||||||
|
let mut result: Vec<i32> = focused
|
||||||
|
.into_iter()
|
||||||
|
.chain(full.into_iter())
|
||||||
|
.collect::<std::collections::BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
result.truncate(12);
|
||||||
|
serde_json::to_string(&result).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn calendar_widget_rows_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<WidgetRowsRequest>(request_json).ok()?;
|
||||||
|
let rows: Vec<Value> = request
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.take(request.max_rows)
|
||||||
|
.map(|item| {
|
||||||
|
let date_iso = item.get("dateIso").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let title = item.get("title").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let subtitle = item
|
||||||
|
.get("episodeTitle")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.or_else(|| item.get("subtitle").and_then(Value::as_str))
|
||||||
|
.unwrap_or("");
|
||||||
|
let season = item.get("seasonNumber").and_then(Value::as_i64);
|
||||||
|
let episode = item.get("episodeNumber").and_then(Value::as_i64);
|
||||||
|
let episode_text = match (season, episode) {
|
||||||
|
(Some(s), Some(e)) => format!("S{}E{}", s, e),
|
||||||
|
(Some(s), None) => format!("S{}", s),
|
||||||
|
(None, Some(e)) => format!("E{}", e),
|
||||||
|
_ => String::new(),
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"dateIso": date_iso,
|
||||||
|
"title": title,
|
||||||
|
"subtitle": subtitle,
|
||||||
|
"episodeText": episode_text
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
serde_json::to_string(&rows).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn calendar_notification_content_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<NotificationContentRequest>(request_json).ok()?;
|
||||||
|
if request.notifications_enabled == Some(false) || request.alert_new_episodes == Some(false) {
|
||||||
|
return serde_json::to_string(&json!({"items": [], "keys": []})).ok();
|
||||||
|
}
|
||||||
|
let profile_id = request.profile_id.as_deref().unwrap_or("");
|
||||||
|
let mut items_out = Vec::new();
|
||||||
|
let mut keys_out = Vec::new();
|
||||||
|
for item in &request.items {
|
||||||
|
if item.date_iso != request.today_iso || item.meta_type != "series" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = format!(
|
||||||
|
"{}:{}:{}:{}",
|
||||||
|
profile_id,
|
||||||
|
item.date_iso,
|
||||||
|
item.meta_id,
|
||||||
|
item.subtitle.as_deref().unwrap_or("")
|
||||||
|
);
|
||||||
|
if request.already_notified_keys.contains(&key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let title_key = if item.episode_number == Some(1) {
|
||||||
|
"notification.new_season_released"
|
||||||
|
} else {
|
||||||
|
"notification.new_episode_released"
|
||||||
|
};
|
||||||
|
let body_text = match (item.season_number, item.episode_number) {
|
||||||
|
(Some(s), Some(e)) => format!("{}:season:{}:episode:{}", item.title, s, e),
|
||||||
|
_ => {
|
||||||
|
[Some(item.title.as_str()), item.subtitle.as_deref()]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" - ")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
items_out.push(json!({
|
||||||
|
"key": key,
|
||||||
|
"titleKey": title_key,
|
||||||
|
"bodyText": body_text,
|
||||||
|
"metaId": item.meta_id,
|
||||||
|
"dateIso": item.date_iso,
|
||||||
|
"artworkUrl": item.artwork_url,
|
||||||
|
"seasonNumber": item.season_number,
|
||||||
|
"episodeNumber": item.episode_number
|
||||||
|
}));
|
||||||
|
keys_out.push(key);
|
||||||
|
}
|
||||||
|
serde_json::to_string(&json!({"items": items_out, "keys": keys_out})).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn calendar_release_detection_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<ReleaseDetectionRequest>(request_json).ok()?;
|
||||||
|
let today = request.today_iso.trim();
|
||||||
|
let released: Vec<&Value> = request
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| {
|
||||||
|
item.get("dateIso")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|d| d == today)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
serde_json::to_string(&released).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn calendar_items_from_meta_json(meta_json: &str, month_prefix: &str) -> Option<String> {
|
||||||
|
let meta: Value = serde_json::from_str(meta_json).ok()?;
|
||||||
|
let meta_id = meta.get("id").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let meta_name = meta.get("name").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let meta_poster = meta.get("poster").and_then(Value::as_str)
|
||||||
|
.or_else(|| meta.get("background").and_then(Value::as_str));
|
||||||
|
let videos = meta.get("videos").and_then(Value::as_array)?;
|
||||||
|
let mut items: Vec<Value> = Vec::new();
|
||||||
|
for video in videos {
|
||||||
|
let released = video.get("released").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let date_iso = if released.len() >= 10 { &released[..10] } else { continue };
|
||||||
|
if !month_prefix.is_empty() && !date_iso.starts_with(month_prefix) { continue; }
|
||||||
|
let season = video.get("season").and_then(Value::as_i64);
|
||||||
|
let episode = video.get("episode").or_else(|| video.get("number")).and_then(Value::as_i64);
|
||||||
|
let episode_code = match (season, episode) {
|
||||||
|
(Some(s), Some(e)) => Some(format!("S{s}:E{e}")),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let video_name = video.get("name").or_else(|| video.get("title")).and_then(Value::as_str);
|
||||||
|
let subtitle = [episode_code.as_deref(), video_name].into_iter().flatten().collect::<Vec<_>>().join(" ");
|
||||||
|
let poster = video.get("thumbnail").and_then(Value::as_str).or(meta_poster);
|
||||||
|
let video_id = video.get("id").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let key = format!("{meta_id}:{video_id}:{date_iso}");
|
||||||
|
items.push(json!({
|
||||||
|
"id": key,
|
||||||
|
"title": meta_name,
|
||||||
|
"name": video_name.unwrap_or(meta_name),
|
||||||
|
"subtitle": subtitle,
|
||||||
|
"dateIso": date_iso,
|
||||||
|
"poster": poster,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
serde_json::to_string(&items).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn calendar_item_matches_month_json(item_json: &str, month_prefix: &str) -> bool {
|
||||||
|
if month_prefix.is_empty() { return true; }
|
||||||
|
serde_json::from_str::<Value>(item_json).ok()
|
||||||
|
.and_then(|v| v.get("dateIso").and_then(Value::as_str).map(|d| d.starts_with(month_prefix)))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_plan_filters_deduplicates_and_sorts_by_date_then_title() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&calendar_content_plan_json(
|
||||||
|
r#"{"monthPrefix":"2026-06","items":[
|
||||||
|
{"dateIso":"2026-06-15","metaId":"tt1","metaType":"series","title":"B","subtitle":"E2"},
|
||||||
|
{"dateIso":"2026-06-10","metaId":"tt2","metaType":"movie","title":"A"},
|
||||||
|
{"dateIso":"2026-06-15","metaId":"tt1","metaType":"series","title":"B","subtitle":"E2"},
|
||||||
|
{"dateIso":"2026-05-01","metaId":"tt3","metaType":"movie","title":"Old"}
|
||||||
|
]}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let arr = result.as_array().unwrap();
|
||||||
|
assert_eq!(arr.len(), 2);
|
||||||
|
assert_eq!(arr[0]["metaId"], "tt2");
|
||||||
|
assert_eq!(arr[1]["metaId"], "tt1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn season_candidates_covers_watched_next_and_last_season() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&calendar_season_candidates_json(
|
||||||
|
r#"{"seasonsCount":5,"lastVideoId":"tt1:2:3"}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let seasons: Vec<i64> = result
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.as_i64().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert!(seasons.contains(&2));
|
||||||
|
assert!(seasons.contains(&3));
|
||||||
|
assert!(seasons.contains(&5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn widget_rows_truncates_to_max_rows() {
|
||||||
|
let items = (0..6)
|
||||||
|
.map(|i| {
|
||||||
|
json!({
|
||||||
|
"dateIso": format!("2026-06-{:02}", i + 1),
|
||||||
|
"title": format!("Show {}", i),
|
||||||
|
"subtitle": "",
|
||||||
|
"seasonNumber": 1,
|
||||||
|
"episodeNumber": i + 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let request = json!({"items": items, "maxRows": 4});
|
||||||
|
let result: Value =
|
||||||
|
serde_json::from_str(&calendar_widget_rows_json(&request.to_string()).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.as_array().unwrap().len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notification_content_skips_already_notified_and_non_today_items() {
|
||||||
|
let request = json!({
|
||||||
|
"items": [
|
||||||
|
{"dateIso":"2026-06-10","metaId":"tt1","metaType":"series","title":"Show","subtitle":"E1","seasonNumber":1,"episodeNumber":1},
|
||||||
|
{"dateIso":"2026-06-11","metaId":"tt2","metaType":"series","title":"Show2","subtitle":"E1","seasonNumber":1,"episodeNumber":1}
|
||||||
|
],
|
||||||
|
"todayIso": "2026-06-10",
|
||||||
|
"alreadyNotifiedKeys": [":2026-06-10:tt1:E1"],
|
||||||
|
"notificationsEnabled": true,
|
||||||
|
"alertNewEpisodes": true
|
||||||
|
});
|
||||||
|
let result: Value =
|
||||||
|
serde_json::from_str(&calendar_notification_content_json(&request.to_string()).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result["items"].as_array().unwrap().len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_detection_returns_only_today_items() {
|
||||||
|
let request = json!({
|
||||||
|
"todayIso": "2026-06-10",
|
||||||
|
"items": [
|
||||||
|
{"dateIso":"2026-06-10","metaId":"tt1"},
|
||||||
|
{"dateIso":"2026-06-11","metaId":"tt2"},
|
||||||
|
{"dateIso":"2026-06-10","metaId":"tt3"}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
let result: Value =
|
||||||
|
serde_json::from_str(&calendar_release_detection_json(&request.to_string()).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.as_array().unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
1372
src/content_identity.rs
Normal file
1372
src/content_identity.rs
Normal file
File diff suppressed because it is too large
Load diff
1168
src/core_api.rs
Normal file
1168
src/core_api.rs
Normal file
File diff suppressed because it is too large
Load diff
194
src/core_contract.rs
Normal file
194
src/core_contract.rs
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
use crate::headless_engine;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// Stable domain-brain contract for platform shells.
|
||||||
|
///
|
||||||
|
/// `CoreBrainSession` wraps the existing headless engine while the app migrates
|
||||||
|
/// from JSON-only calls to typed UniFFI contracts. Dynamic provider payloads
|
||||||
|
/// stay as `serde_json::Value`; action/effect/state ownership is explicit.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CoreBrainSession {
|
||||||
|
handle: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CoreAction {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub action_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub payload: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CoreState {
|
||||||
|
#[serde(default)]
|
||||||
|
pub value: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CoreEffect {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub effect_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub generation: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub payload: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CoreEffectResult {
|
||||||
|
pub effect_id: String,
|
||||||
|
pub status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub value: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CoreDispatchResult {
|
||||||
|
pub state: CoreState,
|
||||||
|
#[serde(default)]
|
||||||
|
pub effects: Vec<CoreEffect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CoreCapabilitySet {
|
||||||
|
pub http: bool,
|
||||||
|
pub storage: bool,
|
||||||
|
pub auth: bool,
|
||||||
|
pub player: bool,
|
||||||
|
pub plugins: bool,
|
||||||
|
pub torrent: bool,
|
||||||
|
pub local_stream: bool,
|
||||||
|
pub notifications: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreCapabilitySet {
|
||||||
|
pub fn android_default() -> Self {
|
||||||
|
Self {
|
||||||
|
http: true,
|
||||||
|
storage: true,
|
||||||
|
auth: true,
|
||||||
|
player: true,
|
||||||
|
plugins: true,
|
||||||
|
torrent: false,
|
||||||
|
local_stream: false,
|
||||||
|
notifications: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn portable_minimum() -> Self {
|
||||||
|
Self {
|
||||||
|
http: true,
|
||||||
|
storage: true,
|
||||||
|
auth: true,
|
||||||
|
player: true,
|
||||||
|
plugins: false,
|
||||||
|
torrent: false,
|
||||||
|
local_stream: false,
|
||||||
|
notifications: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreBrainSession {
|
||||||
|
pub fn new(initial_state: Value) -> Self {
|
||||||
|
let initial_json =
|
||||||
|
serde_json::to_string(&initial_state).unwrap_or_else(|_| "{}".to_string());
|
||||||
|
Self {
|
||||||
|
handle: headless_engine::create_headless_engine(&initial_json),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> Option<CoreState> {
|
||||||
|
headless_engine::headless_engine_snapshot_json(self.handle)
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.map(|value| CoreState { value })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dispatch_json(&self, action_json: &str) -> Option<CoreDispatchResult> {
|
||||||
|
headless_engine::headless_engine_dispatch_json(self.handle, action_json)
|
||||||
|
.and_then(|json| parse_dispatch_result(&json))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn complete_json(&self, result_json: &str) -> Option<CoreDispatchResult> {
|
||||||
|
headless_engine::headless_engine_complete_effect_json(self.handle, result_json)
|
||||||
|
.and_then(|json| parse_dispatch_result(&json))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for CoreBrainSession {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
headless_engine::destroy_headless_engine(self.handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn core_capabilities_json(portable: bool) -> String {
|
||||||
|
let capabilities = if portable {
|
||||||
|
CoreCapabilitySet::portable_minimum()
|
||||||
|
} else {
|
||||||
|
CoreCapabilitySet::android_default()
|
||||||
|
};
|
||||||
|
serde_json::to_string(&capabilities).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_dispatch_result(json: &str) -> Option<CoreDispatchResult> {
|
||||||
|
let raw = serde_json::from_str::<Value>(json).ok()?;
|
||||||
|
let state = raw.get("state").cloned().unwrap_or(Value::Null);
|
||||||
|
let effects = raw
|
||||||
|
.get("effects")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| serde_json::from_value::<CoreEffect>(item.clone()).ok())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
Some(CoreDispatchResult {
|
||||||
|
state: CoreState { value: state },
|
||||||
|
effects,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_session_wraps_headless_state_and_effects() {
|
||||||
|
let session = CoreBrainSession::new(json!({"profile":{"activeProfileId":"p1"}}));
|
||||||
|
let result = session
|
||||||
|
.dispatch_json(r#"{"type":"searchRequested","query":"matrix","language":"en"}"#)
|
||||||
|
.expect("dispatch result");
|
||||||
|
|
||||||
|
assert_eq!(result.effects.len(), 1);
|
||||||
|
assert_eq!(result.effects[0].effect_type, "runSearch");
|
||||||
|
assert_eq!(result.effects[0].payload["profileId"], "p1");
|
||||||
|
assert!(session.snapshot().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capability_sets_make_native_and_portable_differences_explicit() {
|
||||||
|
let native =
|
||||||
|
serde_json::from_str::<CoreCapabilitySet>(&core_capabilities_json(false)).unwrap();
|
||||||
|
let portable =
|
||||||
|
serde_json::from_str::<CoreCapabilitySet>(&core_capabilities_json(true)).unwrap();
|
||||||
|
|
||||||
|
assert!(!native.torrent);
|
||||||
|
assert!(!native.local_stream);
|
||||||
|
assert!(!portable.torrent);
|
||||||
|
assert!(!portable.local_stream);
|
||||||
|
assert!(!portable.plugins);
|
||||||
|
}
|
||||||
|
}
|
||||||
203
src/data_policy.rs
Normal file
203
src/data_policy.rs
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct CacheEntryPolicyInput {
|
||||||
|
key: String,
|
||||||
|
stored_at_millis: i64,
|
||||||
|
ttl_millis: i64,
|
||||||
|
now_millis: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct CacheTrimPolicyInput {
|
||||||
|
#[serde(default)]
|
||||||
|
entries: Vec<CacheTrimEntry>,
|
||||||
|
max_entries: i64,
|
||||||
|
now_millis: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct CacheTrimEntry {
|
||||||
|
key: String,
|
||||||
|
expires_at_millis: i64,
|
||||||
|
stored_at_millis: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DataFailurePolicyInput {
|
||||||
|
operation: String,
|
||||||
|
kind: String,
|
||||||
|
message: Option<String>,
|
||||||
|
throwable_class: Option<String>,
|
||||||
|
reason: Option<String>,
|
||||||
|
status_code: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cache_entry_policy_json(request_json: &str) -> Option<String> {
|
||||||
|
let request: CacheEntryPolicyInput = serde_json::from_str(request_json).ok()?;
|
||||||
|
let ttl = request.ttl_millis.max(1);
|
||||||
|
let expires_at_millis = saturating_add(request.stored_at_millis, ttl);
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"key": request.key,
|
||||||
|
"storedAtMillis": request.stored_at_millis,
|
||||||
|
"expiresAtMillis": expires_at_millis,
|
||||||
|
"isExpired": expires_at_millis <= request.now_millis
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cache_trim_policy_json(request_json: &str) -> Option<String> {
|
||||||
|
let request: CacheTrimPolicyInput = serde_json::from_str(request_json).ok()?;
|
||||||
|
let max_entries = request.max_entries.max(1) as usize;
|
||||||
|
let expired_keys = request
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.expires_at_millis <= request.now_millis)
|
||||||
|
.map(|entry| entry.key.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut live_entries = request
|
||||||
|
.entries
|
||||||
|
.into_iter()
|
||||||
|
.filter(|entry| entry.expires_at_millis > request.now_millis)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
live_entries.sort_by(|a, b| {
|
||||||
|
a.stored_at_millis
|
||||||
|
.cmp(&b.stored_at_millis)
|
||||||
|
.then_with(|| a.key.cmp(&b.key))
|
||||||
|
});
|
||||||
|
let overflow = live_entries.len().saturating_sub(max_entries);
|
||||||
|
let evicted_keys = live_entries
|
||||||
|
.iter()
|
||||||
|
.take(overflow)
|
||||||
|
.map(|entry| entry.key.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"expiredKeys": expired_keys,
|
||||||
|
"evictedKeys": evicted_keys
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn data_failure_policy_json(request_json: &str) -> Option<String> {
|
||||||
|
let request: DataFailurePolicyInput = serde_json::from_str(request_json).ok()?;
|
||||||
|
let kind = normalize_failure_kind(&request.kind);
|
||||||
|
let message = match kind {
|
||||||
|
"auth_unavailable" => "auth_unavailable".to_string(),
|
||||||
|
"unsupported" => request
|
||||||
|
.reason
|
||||||
|
.or(request.message)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "unsupported".to_string()),
|
||||||
|
"network_error" => request
|
||||||
|
.message
|
||||||
|
.or(request.throwable_class)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.or_else(|| request.status_code.map(|code| format!("http_{code}")))
|
||||||
|
.unwrap_or_else(|| "network_error".to_string()),
|
||||||
|
"parse_error" => request
|
||||||
|
.message
|
||||||
|
.or(request.throwable_class)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "parse_error".to_string()),
|
||||||
|
_ => request
|
||||||
|
.message
|
||||||
|
.or(request.throwable_class)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
};
|
||||||
|
let retryable = matches!(kind, "network_error")
|
||||||
|
&& !matches!(request.status_code, Some(400 | 401 | 403 | 404));
|
||||||
|
let stale_fallback_allowed = matches!(kind, "network_error" | "parse_error");
|
||||||
|
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"operation": request.operation,
|
||||||
|
"kind": kind,
|
||||||
|
"message": message,
|
||||||
|
"retryable": retryable,
|
||||||
|
"staleFallbackAllowed": stale_fallback_allowed
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_failure_kind(kind: &str) -> &'static str {
|
||||||
|
match kind.trim() {
|
||||||
|
"auth" | "auth_unavailable" | "AuthUnavailable" => "auth_unavailable",
|
||||||
|
"network" | "network_error" | "NetworkError" => "network_error",
|
||||||
|
"parse" | "parse_error" | "ParseError" => "parse_error",
|
||||||
|
"unsupported" | "Unsupported" => "unsupported",
|
||||||
|
_ => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn saturating_add(left: i64, right: i64) -> i64 {
|
||||||
|
left.checked_add(right).unwrap_or(i64::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_entry_policy_clamps_ttl_and_reports_expiry() {
|
||||||
|
let value: Value = serde_json::from_str(
|
||||||
|
&cache_entry_policy_json(
|
||||||
|
r#"{"key":"k","storedAtMillis":1000,"ttlMillis":0,"nowMillis":1001}"#,
|
||||||
|
)
|
||||||
|
.expect("policy"),
|
||||||
|
)
|
||||||
|
.expect("json");
|
||||||
|
|
||||||
|
assert_eq!(value["expiresAtMillis"].as_i64(), Some(1001));
|
||||||
|
assert_eq!(value["isExpired"].as_bool(), Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_trim_policy_removes_expired_before_oldest_overflow() {
|
||||||
|
let value: Value = serde_json::from_str(
|
||||||
|
&cache_trim_policy_json(
|
||||||
|
r#"{
|
||||||
|
"maxEntries":2,
|
||||||
|
"nowMillis":50,
|
||||||
|
"entries":[
|
||||||
|
{"key":"expired","expiresAtMillis":49,"storedAtMillis":1},
|
||||||
|
{"key":"old","expiresAtMillis":100,"storedAtMillis":2},
|
||||||
|
{"key":"newer","expiresAtMillis":100,"storedAtMillis":3},
|
||||||
|
{"key":"newest","expiresAtMillis":100,"storedAtMillis":4}
|
||||||
|
]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("policy"),
|
||||||
|
)
|
||||||
|
.expect("json");
|
||||||
|
|
||||||
|
assert_eq!(value["expiredKeys"][0].as_str(), Some("expired"));
|
||||||
|
assert_eq!(value["evictedKeys"][0].as_str(), Some("old"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn data_failure_policy_keeps_stable_messages_and_retry_flags() {
|
||||||
|
let auth: Value = serde_json::from_str(
|
||||||
|
&data_failure_policy_json(r#"{"operation":"trakt","kind":"AuthUnavailable"}"#)
|
||||||
|
.expect("policy"),
|
||||||
|
)
|
||||||
|
.expect("json");
|
||||||
|
let network_404: Value = serde_json::from_str(
|
||||||
|
&data_failure_policy_json(
|
||||||
|
r#"{"operation":"catalog","kind":"NetworkError","statusCode":404}"#,
|
||||||
|
)
|
||||||
|
.expect("policy"),
|
||||||
|
)
|
||||||
|
.expect("json");
|
||||||
|
|
||||||
|
assert_eq!(auth["message"].as_str(), Some("auth_unavailable"));
|
||||||
|
assert_eq!(network_404["retryable"].as_bool(), Some(false));
|
||||||
|
assert_eq!(network_404["staleFallbackAllowed"].as_bool(), Some(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
315
src/discovery_plan.rs
Normal file
315
src/discovery_plan.rs
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
use crate::addon_protocol;
|
||||||
|
use crate::content_identity;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct StreamDiscoveryPlanRequest {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
language: String,
|
||||||
|
#[serde(default)]
|
||||||
|
prefer_fast_start: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
addon_request_timeout_ms: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
fast_addon_request_timeout_ms: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
cloudstream_timeout_ms: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
addons: Vec<StreamDiscoveryAddon>,
|
||||||
|
#[serde(default)]
|
||||||
|
cs3_plugin_names: Vec<String>,
|
||||||
|
cs3_search_query: Option<String>,
|
||||||
|
cs3_original_name: Option<String>,
|
||||||
|
cs3_year: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
max_concurrent_addon_requests: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct StreamDiscoveryAddon {
|
||||||
|
transport_url: String,
|
||||||
|
manifest: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct StreamDiscoveryPlan {
|
||||||
|
cache_key: String,
|
||||||
|
addon_requests: Vec<StreamAddonRequest>,
|
||||||
|
cloudstream_request: Option<CloudstreamRequest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct StreamDiscoveryExecutionPolicy {
|
||||||
|
cache_key: String,
|
||||||
|
cache_lookup_prefix: String,
|
||||||
|
max_concurrent_addon_requests: i64,
|
||||||
|
cache_write_minimum_result_count: i64,
|
||||||
|
emit_cached_result: bool,
|
||||||
|
emit_partial_non_empty_results: bool,
|
||||||
|
addon_requests: Vec<StreamAddonRequest>,
|
||||||
|
cloudstream_request: Option<CloudstreamRequest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct StreamAddonRequest {
|
||||||
|
transport_url: String,
|
||||||
|
addon_name: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
timeout_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct CloudstreamRequest {
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
year: Option<i64>,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
content_type: String,
|
||||||
|
season: Option<i32>,
|
||||||
|
episode: Option<i32>,
|
||||||
|
original_name: Option<String>,
|
||||||
|
timeout_ms: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stream_discovery_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<StreamDiscoveryPlanRequest>(request_json).ok()?;
|
||||||
|
let plan = build_stream_discovery_plan(request)?;
|
||||||
|
serde_json::to_string(&StreamDiscoveryPlan {
|
||||||
|
cache_key: plan.cache_key,
|
||||||
|
addon_requests: plan.addon_requests,
|
||||||
|
cloudstream_request: plan.cloudstream_request,
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stream_discovery_cache_prefix(
|
||||||
|
content_type: &str,
|
||||||
|
id: &str,
|
||||||
|
language: &str,
|
||||||
|
) -> String {
|
||||||
|
format!("{content_type}|{id}|{language}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stream_discovery_execution_policy_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<StreamDiscoveryPlanRequest>(request_json).ok()?;
|
||||||
|
let requested_concurrency = request.max_concurrent_addon_requests;
|
||||||
|
let cache_lookup_prefix =
|
||||||
|
stream_discovery_cache_prefix(&request.content_type, &request.id, &request.language);
|
||||||
|
let plan = build_stream_discovery_plan(request)?;
|
||||||
|
let max_concurrent_addon_requests = if requested_concurrency <= 0 {
|
||||||
|
(plan.addon_requests.len() as i64).clamp(1, 32)
|
||||||
|
} else {
|
||||||
|
requested_concurrency.clamp(1, 64)
|
||||||
|
};
|
||||||
|
serde_json::to_string(&StreamDiscoveryExecutionPolicy {
|
||||||
|
cache_key: plan.cache_key,
|
||||||
|
cache_lookup_prefix,
|
||||||
|
max_concurrent_addon_requests,
|
||||||
|
cache_write_minimum_result_count: 1,
|
||||||
|
emit_cached_result: true,
|
||||||
|
emit_partial_non_empty_results: true,
|
||||||
|
addon_requests: plan.addon_requests,
|
||||||
|
cloudstream_request: plan.cloudstream_request,
|
||||||
|
})
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_stream_discovery_plan(request: StreamDiscoveryPlanRequest) -> Option<StreamDiscoveryPlan> {
|
||||||
|
let addon_signatures = request
|
||||||
|
.addons
|
||||||
|
.iter()
|
||||||
|
.map(|addon| {
|
||||||
|
let id = addon
|
||||||
|
.manifest
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("{id}@{}", addon.transport_url)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let cache_key = content_identity::stream_discovery_cache_key(
|
||||||
|
&serde_json::json!({
|
||||||
|
"type": request.content_type,
|
||||||
|
"id": request.id,
|
||||||
|
"language": request.language,
|
||||||
|
"cs3SearchQuery": request.cs3_search_query,
|
||||||
|
"cs3Year": request.cs3_year,
|
||||||
|
"cs3OriginalName": request.cs3_original_name,
|
||||||
|
"addonSignatures": addon_signatures,
|
||||||
|
"cs3PluginNames": request.cs3_plugin_names,
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)?;
|
||||||
|
let timeout_ms = if request.prefer_fast_start {
|
||||||
|
request.fast_addon_request_timeout_ms
|
||||||
|
} else {
|
||||||
|
request.addon_request_timeout_ms
|
||||||
|
};
|
||||||
|
let addon_requests = request
|
||||||
|
.addons
|
||||||
|
.iter()
|
||||||
|
.filter(|addon| {
|
||||||
|
addon_protocol::supports_resource(
|
||||||
|
&addon.manifest.to_string(),
|
||||||
|
"stream",
|
||||||
|
Some(&request.content_type),
|
||||||
|
Some(&request.id),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(|addon| StreamAddonRequest {
|
||||||
|
transport_url: addon.transport_url.clone(),
|
||||||
|
addon_name: addon
|
||||||
|
.manifest
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
content_type: request.content_type.clone(),
|
||||||
|
id: request.id.clone(),
|
||||||
|
timeout_ms,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let episode_locator = content_identity::parse_episode_locator(&request.id);
|
||||||
|
let cloudstream_request = if request.cs3_plugin_names.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
request
|
||||||
|
.cs3_search_query
|
||||||
|
.as_ref()
|
||||||
|
.map(|title| CloudstreamRequest {
|
||||||
|
id: request.id.clone(),
|
||||||
|
title: title.clone(),
|
||||||
|
year: request.cs3_year,
|
||||||
|
content_type: request.content_type.clone(),
|
||||||
|
season: episode_locator.as_ref().map(|(_, season, _)| *season),
|
||||||
|
episode: episode_locator.as_ref().map(|(_, _, episode)| *episode),
|
||||||
|
original_name: request.cs3_original_name.clone(),
|
||||||
|
timeout_ms: request.cloudstream_timeout_ms,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
Some(StreamDiscoveryPlan {
|
||||||
|
cache_key,
|
||||||
|
addon_requests,
|
||||||
|
cloudstream_request,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{
|
||||||
|
stream_discovery_cache_prefix, stream_discovery_execution_policy_json,
|
||||||
|
stream_discovery_plan_json,
|
||||||
|
};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_discovery_plan_filters_by_manifest_without_reordering_addons() {
|
||||||
|
let plan = stream_discovery_plan_json(
|
||||||
|
r#"{
|
||||||
|
"type":"series",
|
||||||
|
"id":"tt1:2:7",
|
||||||
|
"language":"en",
|
||||||
|
"preferFastStart":true,
|
||||||
|
"addonRequestTimeoutMs":1000,
|
||||||
|
"fastAddonRequestTimeoutMs":3000,
|
||||||
|
"cloudstreamTimeoutMs":5000,
|
||||||
|
"cs3PluginNames":["cs"],
|
||||||
|
"cs3SearchQuery":"Show",
|
||||||
|
"cs3OriginalName":"Original Show",
|
||||||
|
"cs3Year":2024,
|
||||||
|
"addons":[
|
||||||
|
{"transportUrl":"https://a/manifest.json","manifest":{"id":"a","name":"A","resources":["stream"],"types":["movie"]}},
|
||||||
|
{"transportUrl":"https://b/manifest.json","manifest":{"id":"b","name":"B","resources":["stream"],"types":["series"]}},
|
||||||
|
{"transportUrl":"https://c/manifest.json","manifest":{"id":"c","name":"C","resources":[{"name":"stream","types":["series"]}],"types":["series"]}}
|
||||||
|
]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("plan");
|
||||||
|
let plan: Value = serde_json::from_str(&plan).expect("plan json");
|
||||||
|
let addon_requests = plan["addonRequests"].as_array().expect("addon requests");
|
||||||
|
|
||||||
|
assert_eq!(addon_requests.len(), 2);
|
||||||
|
assert_eq!(addon_requests[0]["addonName"].as_str(), Some("B"));
|
||||||
|
assert_eq!(addon_requests[1]["addonName"].as_str(), Some("C"));
|
||||||
|
assert_eq!(addon_requests[0]["timeoutMs"].as_i64(), Some(3000));
|
||||||
|
assert_eq!(plan["cloudstreamRequest"]["season"].as_i64(), Some(2));
|
||||||
|
assert_eq!(plan["cloudstreamRequest"]["episode"].as_i64(), Some(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_discovery_execution_policy_owns_cache_and_progress_rules() {
|
||||||
|
let policy = stream_discovery_execution_policy_json(
|
||||||
|
r#"{
|
||||||
|
"type":"movie",
|
||||||
|
"id":"tt1",
|
||||||
|
"language":"tr",
|
||||||
|
"addonRequestTimeoutMs":1000,
|
||||||
|
"fastAddonRequestTimeoutMs":3000,
|
||||||
|
"cloudstreamTimeoutMs":5000,
|
||||||
|
"maxConcurrentAddonRequests":64,
|
||||||
|
"addons":[
|
||||||
|
{"transportUrl":"https://a/manifest.json","manifest":{"id":"a","name":"A","resources":["stream"],"types":["movie"]}},
|
||||||
|
{"transportUrl":"https://b/manifest.json","manifest":{"id":"b","name":"B","resources":["stream"],"types":["movie"]}}
|
||||||
|
],
|
||||||
|
"cs3PluginNames":["cs"],
|
||||||
|
"cs3SearchQuery":"Movie"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("policy");
|
||||||
|
let policy: Value = serde_json::from_str(&policy).expect("policy json");
|
||||||
|
let addon_requests = policy["addonRequests"].as_array().expect("addon requests");
|
||||||
|
|
||||||
|
assert_eq!(policy["cacheLookupPrefix"].as_str(), Some("movie|tt1|tr"));
|
||||||
|
assert_eq!(policy["maxConcurrentAddonRequests"].as_i64(), Some(64));
|
||||||
|
assert_eq!(policy["cacheWriteMinimumResultCount"].as_i64(), Some(1));
|
||||||
|
assert_eq!(policy["emitCachedResult"].as_bool(), Some(true));
|
||||||
|
assert_eq!(policy["emitPartialNonEmptyResults"].as_bool(), Some(true));
|
||||||
|
assert_eq!(addon_requests[0]["addonName"].as_str(), Some("A"));
|
||||||
|
assert_eq!(addon_requests[1]["addonName"].as_str(), Some("B"));
|
||||||
|
assert!(policy["cloudstreamRequest"].is_object());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_discovery_execution_policy_auto_concurrency_uses_addon_count() {
|
||||||
|
let policy = stream_discovery_execution_policy_json(
|
||||||
|
r#"{
|
||||||
|
"type":"movie",
|
||||||
|
"id":"tt2",
|
||||||
|
"language":"en",
|
||||||
|
"addonRequestTimeoutMs":1000,
|
||||||
|
"fastAddonRequestTimeoutMs":3000,
|
||||||
|
"cloudstreamTimeoutMs":5000,
|
||||||
|
"maxConcurrentAddonRequests":0,
|
||||||
|
"addons":[
|
||||||
|
{"transportUrl":"https://a/manifest.json","manifest":{"id":"a","name":"A","resources":["stream"],"types":["movie"]}},
|
||||||
|
{"transportUrl":"https://b/manifest.json","manifest":{"id":"b","name":"B","resources":["stream"],"types":["movie"]}},
|
||||||
|
{"transportUrl":"https://c/manifest.json","manifest":{"id":"c","name":"C","resources":["stream"],"types":["movie"]}}
|
||||||
|
],
|
||||||
|
"cs3PluginNames":[]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("policy");
|
||||||
|
let policy: Value = serde_json::from_str(&policy).expect("policy json");
|
||||||
|
// 3 addons, auto mode → concurrency = min(3, 8) = 3
|
||||||
|
assert_eq!(policy["maxConcurrentAddonRequests"].as_i64(), Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_discovery_cache_prefix_is_owned_by_core() {
|
||||||
|
assert_eq!(
|
||||||
|
stream_discovery_cache_prefix("series", "tt1:1:2", "en"),
|
||||||
|
"series|tt1:1:2|en"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
169
src/dolby_vision_rpu.rs
Normal file
169
src/dolby_vision_rpu.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||||
|
use ::dolby_vision::rpu::dovi_rpu::DoviRpu;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RpuRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
rpu_hex: String,
|
||||||
|
#[serde(default)]
|
||||||
|
rpu_base64: String,
|
||||||
|
#[serde(default = "default_mode")]
|
||||||
|
mode: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct RpuInfo {
|
||||||
|
ok: bool,
|
||||||
|
profile: Option<u8>,
|
||||||
|
el_type: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct RpuConvertResult {
|
||||||
|
ok: bool,
|
||||||
|
profile_before: Option<u8>,
|
||||||
|
profile_after: Option<u8>,
|
||||||
|
el_type_before: Option<String>,
|
||||||
|
el_type_after: Option<String>,
|
||||||
|
rpu_hex: Option<String>,
|
||||||
|
rpu_base64: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_mode() -> u8 {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dolby_vision_rpu_info_json(input: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<RpuRequest>(input).ok()?;
|
||||||
|
let bytes = request.rpu_bytes().map_err(|error| error.to_string());
|
||||||
|
let result = match bytes {
|
||||||
|
Ok(bytes) => match DoviRpu::parse_unspec62_nalu(&bytes) {
|
||||||
|
Ok(rpu) => RpuInfo {
|
||||||
|
ok: true,
|
||||||
|
profile: Some(rpu.dovi_profile),
|
||||||
|
el_type: rpu.el_type.as_ref().map(|value| format!("{value:?}")),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Err(error) => RpuInfo {
|
||||||
|
ok: false,
|
||||||
|
profile: None,
|
||||||
|
el_type: None,
|
||||||
|
error: Some(error.to_string()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Err(error) => RpuInfo {
|
||||||
|
ok: false,
|
||||||
|
profile: None,
|
||||||
|
el_type: None,
|
||||||
|
error: Some(error),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
serde_json::to_string(&result).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dolby_vision_convert_rpu_json(input: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<RpuRequest>(input).ok()?;
|
||||||
|
let bytes = request.rpu_bytes().map_err(|error| error.to_string());
|
||||||
|
let result = match bytes {
|
||||||
|
Ok(bytes) => match DoviRpu::parse_unspec62_nalu(&bytes) {
|
||||||
|
Ok(mut rpu) => {
|
||||||
|
let profile_before = rpu.dovi_profile;
|
||||||
|
let el_type_before = rpu.el_type.as_ref().map(|value| format!("{value:?}"));
|
||||||
|
match rpu.convert_with_mode(request.mode) {
|
||||||
|
Ok(()) => match rpu.write_hevc_unspec62_nalu() {
|
||||||
|
Ok(out) => RpuConvertResult {
|
||||||
|
ok: true,
|
||||||
|
profile_before: Some(profile_before),
|
||||||
|
profile_after: Some(rpu.dovi_profile),
|
||||||
|
el_type_before,
|
||||||
|
el_type_after: rpu.el_type.as_ref().map(|value| format!("{value:?}")),
|
||||||
|
rpu_hex: Some(hex_encode(&out)),
|
||||||
|
rpu_base64: Some(BASE64.encode(&out)),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Err(error) => convert_error(profile_before, el_type_before, error.to_string()),
|
||||||
|
},
|
||||||
|
Err(error) => convert_error(profile_before, el_type_before, error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => convert_error_empty(error.to_string()),
|
||||||
|
},
|
||||||
|
Err(error) => convert_error_empty(error),
|
||||||
|
};
|
||||||
|
serde_json::to_string(&result).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_error(
|
||||||
|
profile_before: u8,
|
||||||
|
el_type_before: Option<String>,
|
||||||
|
error: String,
|
||||||
|
) -> RpuConvertResult {
|
||||||
|
RpuConvertResult {
|
||||||
|
ok: false,
|
||||||
|
profile_before: Some(profile_before),
|
||||||
|
profile_after: None,
|
||||||
|
el_type_before,
|
||||||
|
el_type_after: None,
|
||||||
|
rpu_hex: None,
|
||||||
|
rpu_base64: None,
|
||||||
|
error: Some(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_error_empty(error: String) -> RpuConvertResult {
|
||||||
|
RpuConvertResult {
|
||||||
|
ok: false,
|
||||||
|
profile_before: None,
|
||||||
|
profile_after: None,
|
||||||
|
el_type_before: None,
|
||||||
|
el_type_after: None,
|
||||||
|
rpu_hex: None,
|
||||||
|
rpu_base64: None,
|
||||||
|
error: Some(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RpuRequest {
|
||||||
|
fn rpu_bytes(&self) -> Result<Vec<u8>, String> {
|
||||||
|
if !self.rpu_base64.is_blank() {
|
||||||
|
return BASE64
|
||||||
|
.decode(self.rpu_base64.as_bytes())
|
||||||
|
.map_err(|error| error.to_string());
|
||||||
|
}
|
||||||
|
hex_decode(&self.rpu_hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trait Blank {
|
||||||
|
fn is_blank(&self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Blank for String {
|
||||||
|
fn is_blank(&self) -> bool {
|
||||||
|
self.trim().is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_decode(value: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let clean = value
|
||||||
|
.chars()
|
||||||
|
.filter(|ch| !ch.is_whitespace() && *ch != ':')
|
||||||
|
.collect::<String>();
|
||||||
|
if clean.len() % 2 != 0 {
|
||||||
|
return Err("hex length must be even".to_string());
|
||||||
|
}
|
||||||
|
(0..clean.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|index| {
|
||||||
|
u8::from_str_radix(&clean[index..index + 2], 16)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_encode(bytes: &[u8]) -> String {
|
||||||
|
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
|
}
|
||||||
6
src/env.rs
Normal file
6
src/env.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
pub trait FluxaEnv: Send + Sync + 'static {
|
||||||
|
fn fetch_http(url: &str, timeout_ms: u32) -> Result<(u16, String), String>;
|
||||||
|
fn get_storage(key: &str) -> Result<Option<String>, String>;
|
||||||
|
fn set_storage(key: &str, value: Option<&str>) -> Result<(), String>;
|
||||||
|
fn now_utc_ms() -> i64;
|
||||||
|
}
|
||||||
578
src/external_sync.rs
Normal file
578
src/external_sync.rs
Normal file
|
|
@ -0,0 +1,578 @@
|
||||||
|
use crate::content_identity::{base_content_id, parse_episode_locator};
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
|
||||||
|
const TRAKT_API_BASE_URL: &str = "https://api.trakt.tv";
|
||||||
|
|
||||||
|
pub(crate) fn trakt_has_client(api_key: &str) -> bool {
|
||||||
|
!api_key.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn trakt_bearer(token: &str) -> String {
|
||||||
|
format!("Bearer {token}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn trakt_scrobble_url(action: &str) -> String {
|
||||||
|
format!("{TRAKT_API_BASE_URL}/scrobble/{action}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn trakt_playback_url(content_type: Option<&str>) -> String {
|
||||||
|
match content_type.filter(|value| !value.trim().is_empty()) {
|
||||||
|
Some(content_type) => format!("{TRAKT_API_BASE_URL}/sync/playback/{content_type}"),
|
||||||
|
None => format!("{TRAKT_API_BASE_URL}/sync/playback"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn trakt_token_expires_at(created_at_seconds: i64, expires_in_seconds: i64) -> i64 {
|
||||||
|
let refresh_buffer_seconds = 5 * 60;
|
||||||
|
let effective_expires_in = (expires_in_seconds - refresh_buffer_seconds).max(0);
|
||||||
|
(created_at_seconds * 1000) + (effective_expires_in * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number_to_i32(value: &Value) -> Option<i32> {
|
||||||
|
value.as_i64().and_then(|value| i32::try_from(value).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn trakt_content_id_from_ids_json(ids_json: &str) -> Option<String> {
|
||||||
|
let ids: Value = serde_json::from_str(ids_json).ok()?;
|
||||||
|
ids.get("imdb")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.or_else(|| {
|
||||||
|
ids.get("tmdb")
|
||||||
|
.and_then(number_to_i32)
|
||||||
|
.map(|id| format!("tmdb:{id}"))
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
ids.get("tvdb")
|
||||||
|
.and_then(number_to_i32)
|
||||||
|
.map(|id| format!("tvdb:{id}"))
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
ids.get("slug")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|slug| format!("trakt:{slug}"))
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
ids.get("trakt")
|
||||||
|
.and_then(number_to_i32)
|
||||||
|
.map(|id| format!("trakt:{id}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn trakt_ids_from_content_id_json(raw_id: &str) -> Option<String> {
|
||||||
|
let imdb = regex::Regex::new(r"tt\d+")
|
||||||
|
.ok()?
|
||||||
|
.find(raw_id)
|
||||||
|
.map(|m| m.as_str().to_string());
|
||||||
|
let mut ids = Map::new();
|
||||||
|
if let Some(imdb) = imdb {
|
||||||
|
ids.insert("imdb".to_string(), Value::String(imdb));
|
||||||
|
return serde_json::to_string(&Value::Object(ids)).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix_number = |prefix: &str| {
|
||||||
|
raw_id
|
||||||
|
.strip_prefix(prefix)
|
||||||
|
.and_then(|rest| rest.split(':').next())
|
||||||
|
.and_then(|value| value.parse::<i32>().ok())
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(tmdb) = prefix_number("tmdb:") {
|
||||||
|
ids.insert("tmdb".to_string(), json!(tmdb));
|
||||||
|
} else if let Some(tvdb) = prefix_number("tvdb:") {
|
||||||
|
ids.insert("tvdb".to_string(), json!(tvdb));
|
||||||
|
} else if let Some(trakt) = prefix_number("trakt:") {
|
||||||
|
ids.insert("trakt".to_string(), json!(trakt));
|
||||||
|
} else if let Some(tmdb) = raw_id
|
||||||
|
.split(':')
|
||||||
|
.next()
|
||||||
|
.and_then(|value| value.parse::<i32>().ok())
|
||||||
|
{
|
||||||
|
ids.insert("tmdb".to_string(), json!(tmdb));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ids.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
serde_json::to_string(&Value::Object(ids)).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn trakt_episode_locator_json(video_id: &str) -> Option<String> {
|
||||||
|
let (_, season, episode) = parse_episode_locator(video_id)?;
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"season": season,
|
||||||
|
"episode": episode
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn trakt_show_id_from_episode_id(video_id: &str) -> String {
|
||||||
|
if parse_episode_locator(video_id).is_some() {
|
||||||
|
base_content_id(video_id)
|
||||||
|
} else {
|
||||||
|
video_id.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn trakt_scrobble_media_id(
|
||||||
|
parent_id: &str,
|
||||||
|
video_id: Option<&str>,
|
||||||
|
media_type: &str,
|
||||||
|
) -> String {
|
||||||
|
if media_type != "series" {
|
||||||
|
return video_id.unwrap_or(parent_id).to_string();
|
||||||
|
}
|
||||||
|
let Some(video_id) = video_id.filter(|value| !value.is_empty()) else {
|
||||||
|
return parent_id.to_string();
|
||||||
|
};
|
||||||
|
let Some((_, season, episode)) = parse_episode_locator(video_id) else {
|
||||||
|
return video_id.to_string();
|
||||||
|
};
|
||||||
|
format!("{parent_id}:{season}:{episode}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn trakt_oauth_error_code(body: &str) -> Option<String> {
|
||||||
|
let value: Value = serde_json::from_str(body).ok()?;
|
||||||
|
value
|
||||||
|
.get("error")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn episode_season_number(episode: &Value) -> Option<(i32, i32)> {
|
||||||
|
let parsed = episode
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(parse_episode_locator);
|
||||||
|
let season = episode
|
||||||
|
.get("season")
|
||||||
|
.and_then(number_to_i32)
|
||||||
|
.or_else(|| parsed.as_ref().map(|(_, season, _)| *season));
|
||||||
|
let number = episode
|
||||||
|
.get("number")
|
||||||
|
.and_then(number_to_i32)
|
||||||
|
.or_else(|| parsed.as_ref().map(|(_, _, episode)| *episode));
|
||||||
|
season.zip(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn trakt_history_request_json(meta_json: &str, episodes_json: &str) -> Option<String> {
|
||||||
|
let meta: Value = serde_json::from_str(meta_json).ok()?;
|
||||||
|
let episodes: Vec<Value> = serde_json::from_str(episodes_json).unwrap_or_default();
|
||||||
|
let meta_id = meta.get("id").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let ids_json = trakt_ids_from_content_id_json(meta_id).or_else(|| {
|
||||||
|
episodes
|
||||||
|
.first()
|
||||||
|
.and_then(|episode| episode.get("id").and_then(Value::as_str))
|
||||||
|
.and_then(trakt_ids_from_content_id_json)
|
||||||
|
})?;
|
||||||
|
let ids: Value = serde_json::from_str(&ids_json).ok()?;
|
||||||
|
|
||||||
|
if meta.get("type").and_then(Value::as_str) == Some("movie") {
|
||||||
|
return serde_json::to_string(&json!({
|
||||||
|
"movies": [{ "ids": ids }]
|
||||||
|
}))
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_episodes = if episodes.is_empty() {
|
||||||
|
meta.get("lastVideoId")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.or_else(|| meta.get("id").and_then(Value::as_str))
|
||||||
|
.and_then(parse_episode_locator)
|
||||||
|
.map(|(_, season, episode)| {
|
||||||
|
vec![json!({
|
||||||
|
"season": season,
|
||||||
|
"number": episode
|
||||||
|
})]
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
episodes
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut seasons = std::collections::BTreeMap::<i32, Vec<i32>>::new();
|
||||||
|
for episode in target_episodes.iter().filter_map(episode_season_number) {
|
||||||
|
seasons.entry(episode.0).or_default().push(episode.1);
|
||||||
|
}
|
||||||
|
if seasons.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let seasons = seasons
|
||||||
|
.into_iter()
|
||||||
|
.map(|(season, mut episodes)| {
|
||||||
|
episodes.sort_unstable();
|
||||||
|
episodes.dedup();
|
||||||
|
json!({
|
||||||
|
"number": season,
|
||||||
|
"episodes": episodes.into_iter().map(|number| json!({ "number": number })).collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"shows": [{
|
||||||
|
"ids": ids,
|
||||||
|
"seasons": seasons
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trakt_id_from_source(source: &Value) -> Option<String> {
|
||||||
|
let ids = source.get("ids")?;
|
||||||
|
ids.get("imdb")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.or_else(|| {
|
||||||
|
ids.get("tmdb")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.map(|n| format!("tmdb:{n}"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn trakt_playback_items_to_library_json(items_json: &str) -> Option<String> {
|
||||||
|
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
|
||||||
|
let result: Vec<Value> = items.iter().filter_map(trakt_playback_item_to_library).collect();
|
||||||
|
serde_json::to_string(&result).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trakt_playback_item_to_library(item: &Value) -> Option<Value> {
|
||||||
|
let movie = item.get("movie");
|
||||||
|
let show = item.get("show");
|
||||||
|
let episode = item.get("episode");
|
||||||
|
let source = movie.or(show)?;
|
||||||
|
let id = trakt_id_from_source(source)?;
|
||||||
|
let progress = item.get("progress").and_then(Value::as_f64).unwrap_or(0.0);
|
||||||
|
if progress < 1.0 { return None; }
|
||||||
|
let title = source.get("title").or_else(|| source.get("name"))
|
||||||
|
.and_then(Value::as_str).unwrap_or("Untitled");
|
||||||
|
let episode_title = episode.and_then(|e| e.get("title")).and_then(Value::as_str).unwrap_or("");
|
||||||
|
let ep_runtime = episode.and_then(|e| e.get("runtime")).and_then(Value::as_f64);
|
||||||
|
let runtime_min = ep_runtime
|
||||||
|
.or_else(|| source.get("runtime").and_then(Value::as_f64))
|
||||||
|
.unwrap_or(if movie.is_some() { 100.0 } else { 45.0 });
|
||||||
|
let duration_sec = (runtime_min * 60.0) as i64;
|
||||||
|
let time_offset_sec = ((progress / 100.0) * duration_sec as f64).round() as i64;
|
||||||
|
let content_type = if movie.is_some() { "movie" } else { "series" };
|
||||||
|
let last_video_id = if let Some(ep) = episode {
|
||||||
|
let show_imdb = show
|
||||||
|
.and_then(|s| s.get("ids"))
|
||||||
|
.and_then(|ids| ids.get("imdb"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("trakt");
|
||||||
|
let season = ep.get("season").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let number = ep.get("number").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
format!("{show_imdb}:{season}:{number}")
|
||||||
|
} else {
|
||||||
|
id.clone()
|
||||||
|
};
|
||||||
|
let episode_season = episode.and_then(|e| e.get("season")).and_then(Value::as_i64);
|
||||||
|
let episode_number = episode.and_then(|e| e.get("number")).and_then(Value::as_i64);
|
||||||
|
let saved_at = item.get("paused_at").and_then(Value::as_str).unwrap_or("");
|
||||||
|
Some(json!({
|
||||||
|
"id": id,
|
||||||
|
"name": title,
|
||||||
|
"type": content_type,
|
||||||
|
"timeOffset": time_offset_sec,
|
||||||
|
"duration": duration_sec,
|
||||||
|
"lastVideoId": last_video_id,
|
||||||
|
"lastEpisodeName": if episode_title.is_empty() { Value::Null } else { Value::String(episode_title.to_string()) },
|
||||||
|
"lastEpisodeSeason": episode_season,
|
||||||
|
"lastEpisodeNumber": episode_number,
|
||||||
|
"savedAt": saved_at,
|
||||||
|
"reason": "trakt"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn trakt_watchlist_to_items_json(movies_json: &str, shows_json: &str) -> Option<String> {
|
||||||
|
let movies: Vec<Value> = serde_json::from_str(movies_json).unwrap_or_default();
|
||||||
|
let shows: Vec<Value> = serde_json::from_str(shows_json).unwrap_or_default();
|
||||||
|
let mut items: Vec<Value> = Vec::new();
|
||||||
|
for entry in &movies {
|
||||||
|
let movie = entry.get("movie")?;
|
||||||
|
let id = trakt_id_from_source(movie)?;
|
||||||
|
let name = movie.get("title").and_then(Value::as_str).unwrap_or("");
|
||||||
|
items.push(json!({ "id": id, "name": name, "type": "movie", "source": "trakt" }));
|
||||||
|
}
|
||||||
|
for entry in &shows {
|
||||||
|
let show = entry.get("show")?;
|
||||||
|
let id = trakt_id_from_source(show)?;
|
||||||
|
let name = show.get("title").and_then(Value::as_str).unwrap_or("");
|
||||||
|
items.push(json!({ "id": id, "name": name, "type": "series", "source": "trakt" }));
|
||||||
|
}
|
||||||
|
serde_json::to_string(&items).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn trakt_watched_to_ids_json(movies_json: &str, shows_json: &str) -> Option<String> {
|
||||||
|
let movies: Vec<Value> = serde_json::from_str(movies_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();
|
||||||
|
for entry in &movies {
|
||||||
|
if let Some(imdb) = entry.get("movie")
|
||||||
|
.and_then(|m| m.get("ids"))
|
||||||
|
.and_then(|ids| ids.get("imdb"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
{
|
||||||
|
ids.insert(imdb.to_string(), Value::Bool(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for entry in &shows {
|
||||||
|
let imdb = match entry.get("show")
|
||||||
|
.and_then(|s| s.get("ids"))
|
||||||
|
.and_then(|ids| ids.get("imdb"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
{
|
||||||
|
Some(s) => s,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let seasons = entry.get("seasons").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||||
|
for season in &seasons {
|
||||||
|
let s_num = season.get("number").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let episodes = season.get("episodes").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||||
|
for ep in &episodes {
|
||||||
|
let e_num = ep.get("number").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
if s_num > 0 && e_num > 0 {
|
||||||
|
ids.insert(format!("{imdb}:{s_num}:{e_num}"), Value::Bool(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::to_string(&Value::Object(ids)).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn merge_external_watchlist_json(local_json: &str, external_json: &str) -> String {
|
||||||
|
let mut local: Vec<Value> = serde_json::from_str(local_json).unwrap_or_default();
|
||||||
|
let external: Vec<Value> = serde_json::from_str(external_json).unwrap_or_default();
|
||||||
|
let local_ids: std::collections::HashSet<String> = local.iter()
|
||||||
|
.filter_map(|i| i.get("id").and_then(Value::as_str).map(str::to_string))
|
||||||
|
.collect();
|
||||||
|
for item in external {
|
||||||
|
if let Some(id) = item.get("id").and_then(Value::as_str) {
|
||||||
|
if !local_ids.contains(id) {
|
||||||
|
local.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::to_string(&local).unwrap_or_else(|_| "[]".to_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 external: serde_json::Map<String, Value> = serde_json::from_str(external_json).unwrap_or_default();
|
||||||
|
for (id, val) in external {
|
||||||
|
if val.as_bool() == Some(true) && !local.contains_key(&id) {
|
||||||
|
local.insert(id, Value::Bool(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::to_string(&Value::Object(local)).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn merge_continue_watching_lists_json(
|
||||||
|
local_json: &str,
|
||||||
|
external_json: &str,
|
||||||
|
progress_json: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let local: Vec<Value> = serde_json::from_str(local_json).unwrap_or_default();
|
||||||
|
let external: Vec<Value> = serde_json::from_str(external_json).unwrap_or_default();
|
||||||
|
let progress: serde_json::Map<String, Value> = serde_json::from_str(progress_json).unwrap_or_default();
|
||||||
|
|
||||||
|
fn item_id(item: &Value) -> String {
|
||||||
|
item.get("id").or_else(|| item.get("_id"))
|
||||||
|
.and_then(Value::as_str).unwrap_or("").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn saved_at_ms(item: &Value) -> i64 {
|
||||||
|
item.get("savedAt").and_then(Value::as_str)
|
||||||
|
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||||
|
.map(|dt: chrono::DateTime<chrono::FixedOffset>| dt.timestamp_millis())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_by_id: std::collections::HashMap<String, &Value> = local.iter()
|
||||||
|
.map(|item| (item_id(item), item))
|
||||||
|
.collect();
|
||||||
|
let external_by_id: std::collections::HashMap<String, &Value> = external.iter()
|
||||||
|
.map(|item| (item_id(item), item))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
fn local_saved_at_from_progress(progress: &serde_json::Map<String, Value>, id: &str) -> i64 {
|
||||||
|
progress.get(id)
|
||||||
|
.and_then(|entry| entry.get("savedAt"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||||
|
.map(|dt: chrono::DateTime<chrono::FixedOffset>| dt.timestamp_millis())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut merged: Vec<Value> = Vec::new();
|
||||||
|
for ext_item in &external {
|
||||||
|
let id = item_id(ext_item);
|
||||||
|
let local_time = local_saved_at_from_progress(&progress, &id);
|
||||||
|
let ext_time = saved_at_ms(ext_item);
|
||||||
|
if local_time > ext_time {
|
||||||
|
let local_item = local_by_id.get(&id).copied();
|
||||||
|
merged.push(local_item.cloned().unwrap_or_else(|| ext_item.clone()));
|
||||||
|
} else {
|
||||||
|
merged.push(ext_item.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for local_item in &local {
|
||||||
|
let id = item_id(local_item);
|
||||||
|
if !external_by_id.contains_key(&id) {
|
||||||
|
merged.push(local_item.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::to_string(&merged).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn simkl_watching_to_items_json(shows_json: &str, movies_json: &str) -> Option<String> {
|
||||||
|
let shows: Vec<Value> = serde_json::from_str(shows_json).unwrap_or_default();
|
||||||
|
let movies: Vec<Value> = serde_json::from_str(movies_json).unwrap_or_default();
|
||||||
|
let mut items: Vec<Value> = Vec::new();
|
||||||
|
for entry in &shows {
|
||||||
|
let show = entry.get("show")?;
|
||||||
|
let ids = show.get("ids")?;
|
||||||
|
let imdb = ids.get("imdb").and_then(Value::as_str).filter(|s| !s.is_empty())?;
|
||||||
|
let title = show.get("title").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let poster = show.get("poster").and_then(Value::as_str)
|
||||||
|
.map(|p| format!("https://simkl.in/posters/{p}_m.jpg"));
|
||||||
|
let saved_at = entry.get("last_watched").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
items.push(json!({
|
||||||
|
"id": imdb, "type": "series", "name": title,
|
||||||
|
"poster": poster, "continueWatchingBadge": "upNext",
|
||||||
|
"savedAt": saved_at, "reason": "simkl"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
for entry in &movies {
|
||||||
|
let movie = entry.get("movie")?;
|
||||||
|
let ids = movie.get("ids")?;
|
||||||
|
let imdb = ids.get("imdb").and_then(Value::as_str).filter(|s| !s.is_empty())?;
|
||||||
|
let title = movie.get("title").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let poster = movie.get("poster").and_then(Value::as_str)
|
||||||
|
.map(|p| format!("https://simkl.in/posters/{p}_m.jpg"));
|
||||||
|
let saved_at = entry.get("last_watched").and_then(Value::as_str).unwrap_or_default();
|
||||||
|
items.push(json!({
|
||||||
|
"id": imdb, "type": "movie", "name": title,
|
||||||
|
"poster": poster, "savedAt": saved_at, "reason": "simkl"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
serde_json::to_string(&items).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn simkl_watchlist_to_items_json(shows_json: &str, movies_json: &str) -> Option<String> {
|
||||||
|
let shows: Vec<Value> = serde_json::from_str(shows_json).unwrap_or_default();
|
||||||
|
let movies: Vec<Value> = serde_json::from_str(movies_json).unwrap_or_default();
|
||||||
|
let mut items: Vec<Value> = Vec::new();
|
||||||
|
for entry in &shows {
|
||||||
|
let show = entry.get("show")?;
|
||||||
|
let ids = show.get("ids")?;
|
||||||
|
let imdb = ids.get("imdb").and_then(Value::as_str).filter(|s| !s.is_empty())?;
|
||||||
|
let title = show.get("title").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let poster = show.get("poster").and_then(Value::as_str)
|
||||||
|
.map(|p| format!("https://simkl.in/posters/{p}_m.jpg"));
|
||||||
|
items.push(json!({ "id": imdb, "name": title, "type": "series", "source": "simkl", "poster": poster }));
|
||||||
|
}
|
||||||
|
for entry in &movies {
|
||||||
|
let movie = entry.get("movie")?;
|
||||||
|
let ids = movie.get("ids")?;
|
||||||
|
let imdb = ids.get("imdb").and_then(Value::as_str).filter(|s| !s.is_empty())?;
|
||||||
|
let title = movie.get("title").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let poster = movie.get("poster").and_then(Value::as_str)
|
||||||
|
.map(|p| format!("https://simkl.in/posters/{p}_m.jpg"));
|
||||||
|
items.push(json!({ "id": imdb, "name": title, "type": "movie", "source": "simkl", "poster": poster }));
|
||||||
|
}
|
||||||
|
serde_json::to_string(&items).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn simkl_watched_to_ids_json(shows_json: &str, movies_json: &str) -> Option<String> {
|
||||||
|
let shows: Vec<Value> = serde_json::from_str(shows_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();
|
||||||
|
for entry in &shows {
|
||||||
|
if let Some(imdb) = entry.get("show")
|
||||||
|
.and_then(|s| s.get("ids"))
|
||||||
|
.and_then(|i| i.get("imdb"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
{
|
||||||
|
ids.insert(imdb.to_string(), Value::Bool(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for entry in &movies {
|
||||||
|
if let Some(imdb) = entry.get("movie")
|
||||||
|
.and_then(|m| m.get("ids"))
|
||||||
|
.and_then(|i| i.get("imdb"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
{
|
||||||
|
ids.insert(imdb.to_string(), Value::Bool(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::to_string(&Value::Object(ids)).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trakt_ids_support_stremio_episode_ids() {
|
||||||
|
assert_eq!(
|
||||||
|
trakt_ids_from_content_id_json("tt1234567:1:2")
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.and_then(|ids| ids.get("imdb").and_then(Value::as_str).map(str::to_owned))
|
||||||
|
.as_deref(),
|
||||||
|
Some("tt1234567")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
trakt_ids_from_content_id_json("tmdb:42:1:2")
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.and_then(|ids| ids.get("tmdb").and_then(Value::as_i64)),
|
||||||
|
Some(42)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_request_builds_show_seasons_from_episode_ids() {
|
||||||
|
let request = trakt_history_request_json(
|
||||||
|
r#"{"id":"tt1234567","name":"Show","type":"series","poster":null}"#,
|
||||||
|
r#"[{"id":"tt1234567:1:2","name":null,"season":null,"number":null,"released":null,"thumbnail":null}]"#,
|
||||||
|
)
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.expect("history request");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
request
|
||||||
|
.get("shows")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|shows| shows.first())
|
||||||
|
.and_then(|show| show.get("seasons"))
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.and_then(|seasons| seasons.first())
|
||||||
|
.and_then(|season| season.get("number"))
|
||||||
|
.and_then(Value::as_i64),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
assert!(request.get("movies").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trakt_oauth_error_code_extracts_structured_error() {
|
||||||
|
assert_eq!(
|
||||||
|
trakt_oauth_error_code(r#"{"error":"authorization_pending"}"#).as_deref(),
|
||||||
|
Some("authorization_pending")
|
||||||
|
);
|
||||||
|
assert_eq!(trakt_oauth_error_code("{}"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/headless_adapter_plan.rs
Normal file
183
src/headless_adapter_plan.rs
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
use crate::addon_protocol;
|
||||||
|
use crate::stream_policy;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ProviderAvailabilityRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
addons: Vec<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
plugin_names: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DetailStreamAttempt {
|
||||||
|
request_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
streams: Vec<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DetailStreamResultRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
attempts: Vec<DetailStreamAttempt>,
|
||||||
|
#[serde(default)]
|
||||||
|
has_stream_providers: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct PrefetchPlanRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
streams: Vec<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn provider_availability_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<ProviderAvailabilityRequest>(request_json).ok()?;
|
||||||
|
let has_stremio_stream_addon = request.addons.iter().any(|addon| {
|
||||||
|
addon
|
||||||
|
.get("manifest")
|
||||||
|
.is_some_and(|manifest| addon_protocol::supports_resource(&manifest.to_string(), "stream", None, None))
|
||||||
|
});
|
||||||
|
let plugin_names = stable_non_empty_strings(request.plugin_names);
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"hasStremioStreamAddons": has_stremio_stream_addon,
|
||||||
|
"hasPluginStreamProviders": !plugin_names.is_empty(),
|
||||||
|
"hasStreamProviders": has_stremio_stream_addon || !plugin_names.is_empty(),
|
||||||
|
"pluginNames": plugin_names
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn detail_stream_result_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<DetailStreamResultRequest>(request_json).ok()?;
|
||||||
|
let selected = request
|
||||||
|
.attempts
|
||||||
|
.iter()
|
||||||
|
.find(|attempt| !attempt.streams.is_empty());
|
||||||
|
let streams = selected
|
||||||
|
.map(|attempt| attempt.streams.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let resolved_request_id = selected
|
||||||
|
.map(|attempt| Value::String(attempt.request_id.clone()))
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"streams": streams,
|
||||||
|
"availableAddons": available_addons(&streams),
|
||||||
|
"resolvedRequestId": resolved_request_id,
|
||||||
|
"hasStreamProviders": request.has_stream_providers
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prefetch_detail_streams_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<PrefetchPlanRequest>(request_json).ok()?;
|
||||||
|
let prewarm_url = request.streams.iter().find_map(playable_torrent_url);
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"count": request.streams.len(),
|
||||||
|
"prewarmUrl": prewarm_url,
|
||||||
|
"shouldPrewarmTorrent": prewarm_url.is_some()
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn direct_playback_policy_json() -> String {
|
||||||
|
json!({
|
||||||
|
"metaDetailTimeoutMs": 3500,
|
||||||
|
"streamDetailTimeoutMs": 2500
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn available_addons(streams: &[Value]) -> Vec<String> {
|
||||||
|
streams
|
||||||
|
.iter()
|
||||||
|
.filter_map(|stream| stream.get("addonName").and_then(Value::as_str))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.fold(Vec::new(), |mut acc, value| {
|
||||||
|
if !acc.iter().any(|existing| existing == value) {
|
||||||
|
acc.push(value.to_string());
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn playable_torrent_url(stream: &Value) -> Option<String> {
|
||||||
|
let url = stream
|
||||||
|
.get("playableUrl")
|
||||||
|
.or_else(|| stream.get("url"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.trim().is_empty())?;
|
||||||
|
stream_policy::is_torrent_playback_url(url).then(|| url.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stable_non_empty_strings(values: Vec<String>) -> Vec<String> {
|
||||||
|
values
|
||||||
|
.into_iter()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.fold(Vec::new(), |mut acc, value| {
|
||||||
|
if !acc.contains(&value) {
|
||||||
|
acc.push(value);
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_availability_is_core_planned_from_addons_and_plugins() {
|
||||||
|
let value: Value = serde_json::from_str(
|
||||||
|
&provider_availability_plan_json(
|
||||||
|
r#"{"addons":[{"manifest":{"resources":["catalog",{"name":"stream","types":["movie"]}]}}],"pluginNames":["CS3","CS3"," "]}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["hasStremioStreamAddons"], true);
|
||||||
|
assert_eq!(value["hasPluginStreamProviders"], true);
|
||||||
|
assert_eq!(value["hasStreamProviders"], true);
|
||||||
|
assert_eq!(value["pluginNames"], json!(["CS3"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detail_stream_result_stops_on_first_non_empty_attempt_without_reordering_streams() {
|
||||||
|
let value: Value = serde_json::from_str(
|
||||||
|
&detail_stream_result_plan_json(
|
||||||
|
r#"{"hasStreamProviders":true,"attempts":[{"requestId":"tt1","streams":[]},{"requestId":"tt1:1:1","streams":[{"url":"b","addonName":"B"},{"url":"a","addonName":"A"},{"url":"b2","addonName":"B"}]}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["resolvedRequestId"], "tt1:1:1");
|
||||||
|
assert_eq!(value["streams"][0]["url"], "b");
|
||||||
|
assert_eq!(value["streams"][1]["url"], "a");
|
||||||
|
assert_eq!(value["availableAddons"], json!(["B", "A"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefetch_plan_selects_first_torrent_playable_url_only() {
|
||||||
|
let value: Value = serde_json::from_str(
|
||||||
|
&prefetch_detail_streams_plan_json(
|
||||||
|
r#"{"streams":[{"playableUrl":"https://video.example/file.mp4"},{"playableUrl":"magnet:?xt=urn:btih:abc"},{"playableUrl":"magnet:?xt=urn:btih:def"}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["count"], 3);
|
||||||
|
assert_eq!(value["shouldPrewarmTorrent"], true);
|
||||||
|
assert_eq!(value["prewarmUrl"], "magnet:?xt=urn:btih:abc");
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/headless_engine/addons.rs
Normal file
119
src/headless_engine/addons.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
use super::helpers::{current_generation, normalize_error, upsert_by_key};
|
||||||
|
use super::{EffectResultInput, HeadlessEngine};
|
||||||
|
use crate::runtime::{EffectEnvelope, EffectKind};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub(super) fn dispatch_install(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
transport_url: String,
|
||||||
|
force_refresh: Option<bool>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("addonGeneration");
|
||||||
|
engine.state["addons"]["installing"] = json!(transport_url.clone());
|
||||||
|
engine.state["addons"]["error"] = Value::Null;
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::FetchAddonManifest,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"transportUrl": transport_url,
|
||||||
|
"forceRefresh": force_refresh.unwrap_or(false)
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_refresh(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
profile: Option<Value>,
|
||||||
|
force_refresh: Option<bool>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("addonGeneration");
|
||||||
|
let profile_value = profile.unwrap_or_else(|| engine.state["profile"]["active"].clone());
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::RefreshInstalledAddons,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"profile": profile_value,
|
||||||
|
"forceRefresh": force_refresh.unwrap_or(true)
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_resource(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
transport_url: String,
|
||||||
|
resource: String,
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
extra: Option<Value>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("addonGeneration");
|
||||||
|
engine.state["addons"]["lastResourceRequest"] = json!({
|
||||||
|
"transportUrl": transport_url,
|
||||||
|
"resource": resource,
|
||||||
|
"contentType": content_type,
|
||||||
|
"id": id,
|
||||||
|
"extra": extra.unwrap_or(Value::Null)
|
||||||
|
});
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::FetchAddonResource,
|
||||||
|
generation,
|
||||||
|
engine.state["addons"]["lastResourceRequest"].clone(),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn complete(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
effect_type: &str,
|
||||||
|
generation: u64,
|
||||||
|
result: &EffectResultInput,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
match effect_type {
|
||||||
|
"fetchAddonManifest" => {
|
||||||
|
if generation == current_generation(&engine.state, "addonGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
let manifest = result.value.clone();
|
||||||
|
let id = manifest["transportUrl"]
|
||||||
|
.as_str()
|
||||||
|
.or_else(|| manifest["id"].as_str())
|
||||||
|
.unwrap_or("unknown")
|
||||||
|
.to_string();
|
||||||
|
upsert_by_key(&mut engine.state["addons"]["installed"], "id", &id, manifest);
|
||||||
|
engine.state["addons"]["installing"] = Value::Null;
|
||||||
|
engine.state["addons"]["error"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["addons"]["installing"] = Value::Null;
|
||||||
|
engine.state["addons"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"refreshInstalledAddons" => {
|
||||||
|
if generation == current_generation(&engine.state, "addonGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["home"]["userAddons"] = result
|
||||||
|
.value
|
||||||
|
.get("addons")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| result.value.clone());
|
||||||
|
engine.state["addons"]["installed"] =
|
||||||
|
engine.state["home"]["userAddons"].clone();
|
||||||
|
engine.state["addons"]["error"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["addons"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"fetchAddonResource" => {
|
||||||
|
if generation == current_generation(&engine.state, "addonGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["addons"]["lastResourceResult"] = result.value.clone();
|
||||||
|
engine.state["addons"]["error"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["addons"]["lastResourceResult"] = Value::Null;
|
||||||
|
engine.state["addons"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
117
src/headless_engine/auth.rs
Normal file
117
src/headless_engine/auth.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
use super::helpers::{current_generation, normalize_error};
|
||||||
|
use super::{EffectResultInput, HeadlessEngine};
|
||||||
|
use crate::runtime::{EffectEnvelope, EffectKind};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub(super) fn dispatch_flow(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
provider: String,
|
||||||
|
mode: String,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("authGeneration");
|
||||||
|
engine.state["auth"] = json!({
|
||||||
|
"provider": provider,
|
||||||
|
"mode": mode,
|
||||||
|
"isLoading": true,
|
||||||
|
"error": Value::Null,
|
||||||
|
"generation": generation
|
||||||
|
});
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::RunAuthFlow,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"provider": engine.state["auth"]["provider"].clone(),
|
||||||
|
"mode": engine.state["auth"]["mode"].clone()
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_exchange(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
provider: String,
|
||||||
|
code: String,
|
||||||
|
code_verifier: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("authGeneration");
|
||||||
|
engine.state["auth"] = json!({
|
||||||
|
"provider": provider,
|
||||||
|
"mode": "exchange",
|
||||||
|
"isLoading": true,
|
||||||
|
"error": Value::Null,
|
||||||
|
"generation": generation
|
||||||
|
});
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::ExchangeAuthCode,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"provider": engine.state["auth"]["provider"].clone(),
|
||||||
|
"code": code,
|
||||||
|
"codeVerifier": code_verifier,
|
||||||
|
"profile": profile.unwrap_or_else(|| engine.state["profile"]["active"].clone())
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_token_refresh(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
provider: String,
|
||||||
|
profile: Value,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("authGeneration");
|
||||||
|
engine.state["auth"] = json!({
|
||||||
|
"provider": provider,
|
||||||
|
"mode": "refresh",
|
||||||
|
"isLoading": true,
|
||||||
|
"error": Value::Null,
|
||||||
|
"generation": generation
|
||||||
|
});
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::RefreshAuthToken,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"provider": engine.state["auth"]["provider"].clone(),
|
||||||
|
"profile": profile
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn complete(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
effect_type: &str,
|
||||||
|
generation: u64,
|
||||||
|
result: &EffectResultInput,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
match effect_type {
|
||||||
|
"runAuthFlow" => {
|
||||||
|
if generation == current_generation(&engine.state, "authGeneration") {
|
||||||
|
engine.state["auth"]["isLoading"] = json!(false);
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["auth"]["result"] = result.value.clone();
|
||||||
|
engine.state["auth"]["error"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["auth"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"exchangeAuthCode" | "refreshAuthToken" => {
|
||||||
|
if generation == current_generation(&engine.state, "authGeneration") {
|
||||||
|
engine.state["auth"]["isLoading"] = json!(false);
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["auth"]["result"] = result.value.clone();
|
||||||
|
let updated_profile =
|
||||||
|
engine.state["auth"]["result"].get("profile").cloned();
|
||||||
|
if let Some(updated_profile) = updated_profile {
|
||||||
|
engine.state["profile"]["active"] = updated_profile.clone();
|
||||||
|
engine.state["home"]["activeProfile"] = updated_profile;
|
||||||
|
}
|
||||||
|
engine.state["auth"]["error"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["auth"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
75
src/headless_engine/calendar.rs
Normal file
75
src/headless_engine/calendar.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
use super::helpers::{active_profile_id, current_generation, normalize_error};
|
||||||
|
use super::{EffectResultInput, HeadlessEngine};
|
||||||
|
use crate::runtime::{EffectEnvelope, EffectKind};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub(super) fn dispatch(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
profile: Option<Value>,
|
||||||
|
year: i32,
|
||||||
|
month: i32,
|
||||||
|
planned_items: Option<Value>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("calendarGeneration");
|
||||||
|
let profile_value = profile.unwrap_or_else(|| engine.state["profile"]["active"].clone());
|
||||||
|
engine.state["calendar"] = json!({
|
||||||
|
"year": year,
|
||||||
|
"month": month,
|
||||||
|
"isLoading": true,
|
||||||
|
"items": [],
|
||||||
|
"error": Value::Null,
|
||||||
|
"generation": generation
|
||||||
|
});
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::ReadCalendarMonth,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"profileId": active_profile_id(&engine.state, &profile_value),
|
||||||
|
"profile": profile_value,
|
||||||
|
"year": year,
|
||||||
|
"month": month.clamp(1, 12),
|
||||||
|
"plannedItems": planned_items.unwrap_or_else(|| json!([]))
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn complete(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
generation: u64,
|
||||||
|
result: &EffectResultInput,
|
||||||
|
effect: &Value,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
if generation != current_generation(&engine.state, "calendarGeneration") {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
engine.state["calendar"]["isLoading"] = json!(false);
|
||||||
|
if result.status == "ok" {
|
||||||
|
let items = result.value.get("items").cloned().unwrap_or_else(|| result.value.clone());
|
||||||
|
let local_items = result.value.get("localItems").cloned().unwrap_or_else(|| json!([]));
|
||||||
|
let external_items = result.value.get("externalItems").cloned().unwrap_or_else(|| json!([]));
|
||||||
|
engine.state["calendar"]["items"] = items.clone();
|
||||||
|
engine.state["calendar"]["localItems"] = local_items;
|
||||||
|
engine.state["calendar"]["externalItems"] = external_items.clone();
|
||||||
|
engine.state["calendar"]["error"] = Value::Null;
|
||||||
|
let mut follow_up = vec![
|
||||||
|
engine.effect(EffectKind::UpdateCalendarWidget, generation, json!({
|
||||||
|
"profile": effect["payload"]["profile"].clone(),
|
||||||
|
"items": items.clone()
|
||||||
|
})),
|
||||||
|
engine.effect(EffectKind::NotifyReleasedEpisodes, generation, json!({
|
||||||
|
"profile": effect["payload"]["profile"].clone(),
|
||||||
|
"items": items
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
if external_items.as_array().is_some_and(|i| !i.is_empty()) {
|
||||||
|
follow_up.push(engine.effect(EffectKind::ReplaceExternalContinueWatching, generation, json!({
|
||||||
|
"profileId": effect["payload"]["profileId"].clone(),
|
||||||
|
"items": external_items
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
follow_up
|
||||||
|
} else {
|
||||||
|
engine.state["calendar"]["error"] = normalize_error(result.error.clone());
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
321
src/headless_engine/contracts.rs
Normal file
321
src/headless_engine/contracts.rs
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
use crate::runtime::EffectEnvelope;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(
|
||||||
|
rename_all = "camelCase",
|
||||||
|
rename_all_fields = "camelCase",
|
||||||
|
tag = "type"
|
||||||
|
)]
|
||||||
|
pub(super) enum AppAction {
|
||||||
|
#[serde(rename = "navigationRequested")]
|
||||||
|
NavigationRequested {
|
||||||
|
route: String,
|
||||||
|
params: Option<Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "detailLoadRequested")]
|
||||||
|
DetailLoadRequested {
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
language: Option<String>,
|
||||||
|
source_addon_transport_url: Option<String>,
|
||||||
|
source_addon_catalog_type: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "detailLocalStateRequested")]
|
||||||
|
DetailLocalStateRequested {
|
||||||
|
primary_id: String,
|
||||||
|
fallback_id: Option<String>,
|
||||||
|
content_type: String,
|
||||||
|
profile: Option<Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "detailSecondaryRequested")]
|
||||||
|
DetailSecondaryRequested {
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
language: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "detailPrefetchRequested")]
|
||||||
|
DetailPrefetchRequested {
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
stream_lookup_id: String,
|
||||||
|
title: Option<String>,
|
||||||
|
original_name: Option<String>,
|
||||||
|
year: Option<i32>,
|
||||||
|
language: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "detailStreamsRequested")]
|
||||||
|
DetailStreamsRequested {
|
||||||
|
content_type: String,
|
||||||
|
request_ids: Vec<String>,
|
||||||
|
detail: Option<Value>,
|
||||||
|
season_episodes: Option<Vec<Value>>,
|
||||||
|
language: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "detailSelectedAddonChanged")]
|
||||||
|
DetailSelectedAddonChanged { addon: Option<String> },
|
||||||
|
#[serde(rename = "metaDetailRequested")]
|
||||||
|
MetaDetailRequested {
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
language: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "directPlaybackRequested")]
|
||||||
|
DirectPlaybackRequested {
|
||||||
|
meta: Value,
|
||||||
|
language: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "introSegmentsRequested")]
|
||||||
|
IntroSegmentsRequested {
|
||||||
|
imdb_id: String,
|
||||||
|
season: i32,
|
||||||
|
episode: i32,
|
||||||
|
title: Option<String>,
|
||||||
|
use_intro_db: bool,
|
||||||
|
use_ani_skip: bool,
|
||||||
|
},
|
||||||
|
#[serde(rename = "introImdbIdRequested")]
|
||||||
|
IntroImdbIdRequested {
|
||||||
|
meta: Value,
|
||||||
|
video_id: Option<String>,
|
||||||
|
language: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "playerLoadStreamsRequested")]
|
||||||
|
PlayerLoadStreamsRequested {
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
current_video_id: Option<String>,
|
||||||
|
initial_video_id: Option<String>,
|
||||||
|
initial_streams: Option<Vec<Value>>,
|
||||||
|
initial_stream_index: Option<i32>,
|
||||||
|
saved_url: Option<String>,
|
||||||
|
saved_title: Option<String>,
|
||||||
|
source_selection_mode: Option<String>,
|
||||||
|
regex_pattern: Option<String>,
|
||||||
|
preferred_binge_group: Option<String>,
|
||||||
|
title: Option<String>,
|
||||||
|
original_name: Option<String>,
|
||||||
|
year: Option<i32>,
|
||||||
|
language: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "playerStreamsLoaded")]
|
||||||
|
PlayerStreamsLoaded {
|
||||||
|
streams: Vec<Value>,
|
||||||
|
current_video_id: Option<String>,
|
||||||
|
initial_stream_index: Option<i32>,
|
||||||
|
saved_url: Option<String>,
|
||||||
|
saved_title: Option<String>,
|
||||||
|
source_selection_mode: Option<String>,
|
||||||
|
regex_pattern: Option<String>,
|
||||||
|
preferred_binge_group: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "playerStreamsFailed")]
|
||||||
|
PlayerStreamsFailed { error_code: Option<String> },
|
||||||
|
#[serde(rename = "playerResolvePlaybackRequested")]
|
||||||
|
PlayerResolvePlaybackRequested {
|
||||||
|
url: String,
|
||||||
|
stream: Option<Value>,
|
||||||
|
current_video_id: Option<String>,
|
||||||
|
title: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "scrobbleRequested")]
|
||||||
|
ScrobbleRequested {
|
||||||
|
token: String,
|
||||||
|
meta_type: String,
|
||||||
|
item_id: String,
|
||||||
|
progress: f64,
|
||||||
|
action_name: String,
|
||||||
|
profile: Option<Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "profileActivated")]
|
||||||
|
ProfileActivated { profile: Value },
|
||||||
|
#[serde(rename = "homeLoadRequested")]
|
||||||
|
HomeLoadRequested {
|
||||||
|
profile: Option<Value>,
|
||||||
|
language: Option<String>,
|
||||||
|
force: Option<bool>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "libraryHydrateRequested")]
|
||||||
|
LibraryHydrateRequested { profile_id: Option<String> },
|
||||||
|
#[serde(rename = "toggleWatchlistRequested")]
|
||||||
|
ToggleWatchlistRequested { item: Value },
|
||||||
|
#[serde(rename = "setFeedbackRequested")]
|
||||||
|
SetFeedbackRequested {
|
||||||
|
id: String,
|
||||||
|
value: Option<bool>,
|
||||||
|
meta: Value,
|
||||||
|
},
|
||||||
|
#[serde(rename = "clearPlaybackProgressRequested")]
|
||||||
|
ClearPlaybackProgressRequested { profile: Option<Value>, meta: Value },
|
||||||
|
#[serde(rename = "savePlaybackProgressRequested")]
|
||||||
|
SavePlaybackProgressRequested {
|
||||||
|
profile: Option<Value>,
|
||||||
|
meta: Value,
|
||||||
|
time_offset: i64,
|
||||||
|
duration: i64,
|
||||||
|
last_video_id: Option<String>,
|
||||||
|
last_stream_index: Option<i32>,
|
||||||
|
last_episode_name: Option<String>,
|
||||||
|
last_episode_season: Option<i64>,
|
||||||
|
last_episode_number: Option<i64>,
|
||||||
|
last_episode_thumbnail: Option<String>,
|
||||||
|
last_stream_url: Option<String>,
|
||||||
|
last_stream_title: Option<String>,
|
||||||
|
last_audio_language: Option<String>,
|
||||||
|
last_subtitle_language: Option<String>,
|
||||||
|
scrobble_trakt_pause: Option<bool>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "markWatchedRequested")]
|
||||||
|
MarkWatchedRequested {
|
||||||
|
series_id: String,
|
||||||
|
video_ids: Vec<String>,
|
||||||
|
watched: Option<bool>,
|
||||||
|
meta: Option<Value>,
|
||||||
|
episodes: Option<Vec<Value>>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "addonInstallRequested")]
|
||||||
|
AddonInstallRequested {
|
||||||
|
transport_url: String,
|
||||||
|
force_refresh: Option<bool>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "addonsRefreshRequested")]
|
||||||
|
AddonsRefreshRequested {
|
||||||
|
profile: Option<Value>,
|
||||||
|
force_refresh: Option<bool>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "addonResourceRequested")]
|
||||||
|
AddonResourceRequested {
|
||||||
|
transport_url: String,
|
||||||
|
resource: String,
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
extra: Option<Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "searchRequested")]
|
||||||
|
SearchRequested {
|
||||||
|
query: String,
|
||||||
|
profile: Option<Value>,
|
||||||
|
language: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "discoverRequested")]
|
||||||
|
DiscoverRequested {
|
||||||
|
content_type: String,
|
||||||
|
filters: Option<Value>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
language: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "discoverCatalogFiltersRequested")]
|
||||||
|
DiscoverCatalogFiltersRequested {
|
||||||
|
content_type: String,
|
||||||
|
selected_catalog_key: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
language: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "catalogPageRequested")]
|
||||||
|
CatalogPageRequested {
|
||||||
|
category_id: String,
|
||||||
|
transport_url: Option<String>,
|
||||||
|
content_type: String,
|
||||||
|
catalog_id: String,
|
||||||
|
skip: Option<i32>,
|
||||||
|
genre: Option<String>,
|
||||||
|
search: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "detailSeasonRequested")]
|
||||||
|
DetailSeasonRequested {
|
||||||
|
series_id: String,
|
||||||
|
season: i32,
|
||||||
|
profile: Option<Value>,
|
||||||
|
language: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "playerNextEpisodeCardShown")]
|
||||||
|
PlayerNextEpisodeCardShown {
|
||||||
|
content_type: String,
|
||||||
|
series_id: String,
|
||||||
|
next_video_id: String,
|
||||||
|
title: Option<String>,
|
||||||
|
original_name: Option<String>,
|
||||||
|
year: Option<i32>,
|
||||||
|
language: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "subtitleLoadRequested")]
|
||||||
|
SubtitleLoadRequested {
|
||||||
|
stream: Value,
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
extra_args: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "externalSyncRequested")]
|
||||||
|
ExternalSyncRequested {
|
||||||
|
provider: String,
|
||||||
|
profile: Option<Value>,
|
||||||
|
language: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "authFlowRequested")]
|
||||||
|
AuthFlowRequested { provider: String, mode: String },
|
||||||
|
#[serde(rename = "authExchangeRequested")]
|
||||||
|
AuthExchangeRequested {
|
||||||
|
provider: String,
|
||||||
|
code: String,
|
||||||
|
code_verifier: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "authRefreshRequested")]
|
||||||
|
AuthRefreshRequested { provider: String, profile: Value },
|
||||||
|
#[serde(rename = "externalIntegrationSyncRequested")]
|
||||||
|
ExternalIntegrationSyncRequested {
|
||||||
|
provider: String,
|
||||||
|
profile: Value,
|
||||||
|
language: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "settingsChanged")]
|
||||||
|
SettingsChanged { key: String, value: Value },
|
||||||
|
#[serde(rename = "calendarMonthRequested")]
|
||||||
|
CalendarMonthRequested {
|
||||||
|
profile: Option<Value>,
|
||||||
|
year: i32,
|
||||||
|
month: i32,
|
||||||
|
#[serde(rename = "plannedItems")]
|
||||||
|
planned_items: Option<Value>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "offlineDownloadRequested")]
|
||||||
|
OfflineDownloadRequested {
|
||||||
|
meta: Value,
|
||||||
|
stream: Value,
|
||||||
|
video_id: Option<String>,
|
||||||
|
video: Option<Value>,
|
||||||
|
subtitle: Option<Value>,
|
||||||
|
profile_id: Option<String>,
|
||||||
|
language: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct EffectResultInput {
|
||||||
|
#[serde(default)]
|
||||||
|
pub effect_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub status: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub value: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(super) struct DispatchResult {
|
||||||
|
pub state: Value,
|
||||||
|
pub effects: Vec<EffectEnvelope>,
|
||||||
|
}
|
||||||
383
src/headless_engine/detail.rs
Normal file
383
src/headless_engine/detail.rs
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
use super::helpers::{
|
||||||
|
active_profile_id, current_generation, normalize_error, normalize_meta_trailers,
|
||||||
|
value_array_is_empty, visible_streams,
|
||||||
|
};
|
||||||
|
use super::{EffectResultInput, HeadlessEngine};
|
||||||
|
use crate::runtime::{EffectEnvelope, EffectKind};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub(super) fn dispatch_load(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
language: Option<String>,
|
||||||
|
source_addon_transport_url: Option<String>,
|
||||||
|
source_addon_catalog_type: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("detailGeneration");
|
||||||
|
engine.state["detail"] = json!({
|
||||||
|
"contentType": content_type,
|
||||||
|
"id": id,
|
||||||
|
"language": language.clone().unwrap_or_else(|| "en".to_string()),
|
||||||
|
"profile": profile.clone().unwrap_or(Value::Null),
|
||||||
|
"isLoading": true,
|
||||||
|
"isLoadingStreams": false,
|
||||||
|
"meta": Value::Null,
|
||||||
|
"streams": [],
|
||||||
|
"visibleStreams": [],
|
||||||
|
"selectedAddon": Value::Null,
|
||||||
|
"availableAddons": [],
|
||||||
|
"loadingAddonNames": [],
|
||||||
|
"seasonEpisodes": [],
|
||||||
|
"savedPlayback": Value::Null,
|
||||||
|
"watchedVideoIds": [],
|
||||||
|
"similarItems": [],
|
||||||
|
"trailers": [],
|
||||||
|
"error": Value::Null,
|
||||||
|
"generation": generation
|
||||||
|
});
|
||||||
|
vec![
|
||||||
|
engine.effect(
|
||||||
|
EffectKind::FetchMetaDetail,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"contentType": content_type,
|
||||||
|
"id": id,
|
||||||
|
"language": language.unwrap_or_else(|| "en".to_string()),
|
||||||
|
"sourceAddonTransportUrl": source_addon_transport_url.unwrap_or_default(),
|
||||||
|
"sourceAddonCatalogType": source_addon_catalog_type.unwrap_or_default(),
|
||||||
|
"profile": profile.unwrap_or(Value::Null)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
engine.effect(
|
||||||
|
EffectKind::ReadPlaybackProgress,
|
||||||
|
generation,
|
||||||
|
json!({ "id": engine.state["detail"]["id"].clone() }),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_local_state(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
primary_id: String,
|
||||||
|
fallback_id: Option<String>,
|
||||||
|
content_type: String,
|
||||||
|
profile: Option<Value>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = current_generation(&engine.state, "detailGeneration")
|
||||||
|
.max(engine.bump_generation("detailGeneration"));
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::ReadDetailLocalState,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"primaryId": primary_id,
|
||||||
|
"fallbackId": fallback_id,
|
||||||
|
"contentType": content_type,
|
||||||
|
"profile": profile.unwrap_or(Value::Null)
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_secondary(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
language: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = current_generation(&engine.state, "detailGeneration")
|
||||||
|
.max(engine.bump_generation("detailGeneration"));
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::FetchDetailSecondary,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"contentType": content_type,
|
||||||
|
"id": id,
|
||||||
|
"language": language.unwrap_or_else(|| "en".to_string()),
|
||||||
|
"profile": profile.unwrap_or(Value::Null)
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn dispatch_prefetch(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
stream_lookup_id: String,
|
||||||
|
title: Option<String>,
|
||||||
|
original_name: Option<String>,
|
||||||
|
year: Option<i32>,
|
||||||
|
language: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = current_generation(&engine.state, "detailGeneration");
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::PrefetchDetailStreams,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"contentType": content_type,
|
||||||
|
"id": id,
|
||||||
|
"streamLookupId": stream_lookup_id,
|
||||||
|
"title": title.unwrap_or_default(),
|
||||||
|
"originalName": original_name,
|
||||||
|
"year": year,
|
||||||
|
"language": language.unwrap_or_else(|| "en".to_string()),
|
||||||
|
"profile": profile.unwrap_or(Value::Null)
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_streams(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
content_type: String,
|
||||||
|
request_ids: Vec<String>,
|
||||||
|
detail: Option<Value>,
|
||||||
|
season_episodes: Option<Vec<Value>>,
|
||||||
|
language: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("detailStreamsGeneration");
|
||||||
|
engine.state["detail"]["isLoadingStreams"] = json!(true);
|
||||||
|
engine.state["detail"]["streams"] = json!([]);
|
||||||
|
engine.state["detail"]["visibleStreams"] = json!([]);
|
||||||
|
engine.state["detail"]["selectedAddon"] = Value::Null;
|
||||||
|
engine.state["detail"]["availableAddons"] = json!([]);
|
||||||
|
engine.state["detail"]["loadingAddonNames"] = json!([]);
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::FetchDetailStreams,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"contentType": content_type,
|
||||||
|
"requestIds": request_ids,
|
||||||
|
"detail": detail.unwrap_or(Value::Null),
|
||||||
|
"seasonEpisodes": season_episodes.unwrap_or_default(),
|
||||||
|
"language": language.unwrap_or_else(|| "en".to_string()),
|
||||||
|
"profile": profile.unwrap_or(Value::Null)
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_selected_addon_changed(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
addon: Option<String>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let selected = addon.and_then(|value| {
|
||||||
|
let trimmed = value.trim().to_string();
|
||||||
|
if trimmed.is_empty() { None } else { Some(trimmed) }
|
||||||
|
});
|
||||||
|
engine.state["detail"]["selectedAddon"] =
|
||||||
|
selected.as_ref().map(|value| json!(value)).unwrap_or(Value::Null);
|
||||||
|
engine.state["detail"]["visibleStreams"] =
|
||||||
|
visible_streams(&engine.state["detail"]["streams"], selected.as_deref());
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_meta_detail(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
language: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("lookupGeneration");
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::FetchMetaDetailLookup,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"contentType": content_type,
|
||||||
|
"id": id,
|
||||||
|
"language": language.unwrap_or_else(|| "en".to_string()),
|
||||||
|
"profile": profile.unwrap_or(Value::Null)
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_season(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
series_id: String,
|
||||||
|
season: i32,
|
||||||
|
profile: Option<Value>,
|
||||||
|
language: Option<String>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = current_generation(&engine.state, "detailGeneration")
|
||||||
|
.max(engine.bump_generation("detailGeneration"));
|
||||||
|
let profile_value = profile.unwrap_or_else(|| engine.state["profile"]["active"].clone());
|
||||||
|
engine.state["detail"]["seasonLoading"] = json!(season);
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::FetchSeasonEpisodes,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"seriesId": series_id,
|
||||||
|
"season": season.max(0),
|
||||||
|
"profileId": active_profile_id(&engine.state, &profile_value),
|
||||||
|
"profile": profile_value,
|
||||||
|
"language": language.unwrap_or_else(|| "en".to_string())
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn complete(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
effect_type: &str,
|
||||||
|
generation: u64,
|
||||||
|
result: &EffectResultInput,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
match effect_type {
|
||||||
|
"fetchMetaDetail" => {
|
||||||
|
if generation == current_generation(&engine.state, "detailGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["detail"]["trailers"] = normalize_meta_trailers(&result.value);
|
||||||
|
engine.state["detail"]["meta"] = result.value.clone();
|
||||||
|
engine.state["detail"]["isLoading"] = json!(false);
|
||||||
|
engine.state["detail"]["error"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["detail"]["isLoading"] = json!(false);
|
||||||
|
engine.state["detail"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"readPlaybackProgress" => {
|
||||||
|
if generation == current_generation(&engine.state, "detailGeneration") {
|
||||||
|
engine.state["detail"]["savedPlayback"] = if result.status == "ok" {
|
||||||
|
result.value.clone()
|
||||||
|
} else {
|
||||||
|
Value::Null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"readDetailLocalState" => {
|
||||||
|
if generation == current_generation(&engine.state, "detailGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["detail"]["savedPlayback"] =
|
||||||
|
result.value.get("savedPlayback").cloned().unwrap_or(Value::Null);
|
||||||
|
engine.state["detail"]["localWatchedVideoIds"] = result
|
||||||
|
.value
|
||||||
|
.get("localWatchedVideoIds")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
engine.state["detail"]["isInWatchlist"] = result
|
||||||
|
.value
|
||||||
|
.get("isInWatchlist")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!(false));
|
||||||
|
engine.state["detail"]["feedback"] =
|
||||||
|
result.value.get("feedback").cloned().unwrap_or(Value::Null);
|
||||||
|
engine.state["detail"]["hasStreamProviders"] = result
|
||||||
|
.value
|
||||||
|
.get("hasStreamProviders")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!(false));
|
||||||
|
engine.state["detail"]["userAddons"] = result
|
||||||
|
.value
|
||||||
|
.get("userAddons")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
} else {
|
||||||
|
engine.state["detail"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"fetchDetailSecondary" => {
|
||||||
|
if generation == current_generation(&engine.state, "detailGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["detail"]["watchedVideoIds"] = result
|
||||||
|
.value
|
||||||
|
.get("watchedVideoIds")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
engine.state["detail"]["similarItems"] = result
|
||||||
|
.value
|
||||||
|
.get("similarItems")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
if value_array_is_empty(&engine.state["detail"]["trailers"]) {
|
||||||
|
engine.state["detail"]["trailers"] = result
|
||||||
|
.value
|
||||||
|
.get("trailers")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
engine.state["detail"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"prefetchDetailStreams" => {
|
||||||
|
if generation == current_generation(&engine.state, "detailGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["detail"]["lastPrefetch"] = result.value.clone();
|
||||||
|
} else {
|
||||||
|
engine.state["detail"]["lastPrefetchError"] =
|
||||||
|
normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"fetchDetailStreams" => {
|
||||||
|
if generation == current_generation(&engine.state, "detailStreamsGeneration") {
|
||||||
|
engine.state["detail"]["isLoadingStreams"] = json!(false);
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["detail"]["streams"] = result
|
||||||
|
.value
|
||||||
|
.get("streams")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
engine.state["detail"]["selectedAddon"] = Value::Null;
|
||||||
|
engine.state["detail"]["visibleStreams"] =
|
||||||
|
engine.state["detail"]["streams"].clone();
|
||||||
|
engine.state["detail"]["availableAddons"] = result
|
||||||
|
.value
|
||||||
|
.get("availableAddons")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
engine.state["detail"]["loadingAddonNames"] = json!([]);
|
||||||
|
engine.state["detail"]["resolvedRequestId"] = result
|
||||||
|
.value
|
||||||
|
.get("resolvedRequestId")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
engine.state["detail"]["hasStreamProviders"] = result
|
||||||
|
.value
|
||||||
|
.get("hasStreamProviders")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!(false));
|
||||||
|
engine.state["detail"]["streamsError"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["detail"]["streamsError"] = normalize_error(result.error.clone());
|
||||||
|
engine.state["detail"]["loadingAddonNames"] = json!([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"fetchMetaDetailLookup" => {
|
||||||
|
if generation == current_generation(&engine.state, "lookupGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["lookup"]["trailers"] = normalize_meta_trailers(&result.value);
|
||||||
|
engine.state["lookup"]["metaDetail"] = result.value.clone();
|
||||||
|
engine.state["lookup"]["error"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["lookup"]["metaDetail"] = Value::Null;
|
||||||
|
engine.state["lookup"]["trailers"] = json!([]);
|
||||||
|
engine.state["lookup"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"fetchSeasonEpisodes" => {
|
||||||
|
if generation == current_generation(&engine.state, "detailGeneration") {
|
||||||
|
engine.state["detail"]["seasonLoading"] = Value::Null;
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["detail"]["seasonEpisodes"] = result
|
||||||
|
.value
|
||||||
|
.get("episodes")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| result.value.clone());
|
||||||
|
engine.state["detail"]["error"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["detail"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
101
src/headless_engine/discover.rs
Normal file
101
src/headless_engine/discover.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
use super::helpers::{active_profile_id, current_generation, normalize_error};
|
||||||
|
use super::{EffectResultInput, HeadlessEngine};
|
||||||
|
use crate::runtime::{EffectEnvelope, EffectKind};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub(super) fn dispatch_discover(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
content_type: String,
|
||||||
|
filters: Option<Value>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
language: Option<String>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("discoverGeneration");
|
||||||
|
let profile_value = profile.unwrap_or_else(|| engine.state["profile"]["active"].clone());
|
||||||
|
engine.state["discover"] = json!({
|
||||||
|
"contentType": content_type,
|
||||||
|
"filters": filters.clone().unwrap_or(Value::Null),
|
||||||
|
"isLoading": true,
|
||||||
|
"results": [],
|
||||||
|
"error": Value::Null,
|
||||||
|
"generation": generation
|
||||||
|
});
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::RunDiscover,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"contentType": engine.state["discover"]["contentType"].clone(),
|
||||||
|
"filters": engine.state["discover"]["filters"].clone(),
|
||||||
|
"profileId": active_profile_id(&engine.state, &profile_value),
|
||||||
|
"profile": profile_value,
|
||||||
|
"language": language.unwrap_or_else(|| "en".to_string())
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_catalog_filters(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
content_type: String,
|
||||||
|
selected_catalog_key: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
language: Option<String>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("discoverGeneration");
|
||||||
|
let profile_value = profile.unwrap_or_else(|| engine.state["profile"]["active"].clone());
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::ReadDiscoverCatalogFilters,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"contentType": content_type,
|
||||||
|
"selectedCatalogKey": selected_catalog_key,
|
||||||
|
"profileId": active_profile_id(&engine.state, &profile_value),
|
||||||
|
"profile": profile_value,
|
||||||
|
"language": language.unwrap_or_else(|| "en".to_string())
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn complete(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
effect_type: &str,
|
||||||
|
generation: u64,
|
||||||
|
result: &EffectResultInput,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
match effect_type {
|
||||||
|
"runDiscover" => {
|
||||||
|
if generation == current_generation(&engine.state, "discoverGeneration") {
|
||||||
|
engine.state["discover"]["isLoading"] = json!(false);
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["discover"]["results"] = result
|
||||||
|
.value
|
||||||
|
.get("results")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| result.value.clone());
|
||||||
|
engine.state["discover"]["resultSources"] = result
|
||||||
|
.value
|
||||||
|
.get("resultSources")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!({}));
|
||||||
|
engine.state["discover"]["error"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["discover"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"readDiscoverCatalogFilters" => {
|
||||||
|
if generation == current_generation(&engine.state, "discoverGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["discover"]["catalogs"] =
|
||||||
|
result.value.get("catalogs").cloned().unwrap_or_else(|| json!([]));
|
||||||
|
engine.state["discover"]["genres"] =
|
||||||
|
result.value.get("genres").cloned().unwrap_or_else(|| json!([]));
|
||||||
|
engine.state["discover"]["error"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["discover"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
172
src/headless_engine/helpers.rs
Normal file
172
src/headless_engine/helpers.rs
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub(super) fn current_generation(state: &Value, key: &str) -> u64 {
|
||||||
|
state["_runtime"][key].as_u64().unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn pending_effect(state: &Value, effect_id: &str) -> Option<Value> {
|
||||||
|
state["pendingEffects"]
|
||||||
|
.as_array()?
|
||||||
|
.iter()
|
||||||
|
.find(|effect| effect["id"].as_str() == Some(effect_id))
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn remove_pending_effect(state: &mut Value, effect_id: &str) {
|
||||||
|
if let Some(effects) = state["pendingEffects"].as_array_mut() {
|
||||||
|
effects.retain(|effect| effect["id"].as_str() != Some(effect_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_error(error: Value) -> Value {
|
||||||
|
if error.is_null() {
|
||||||
|
json!({ "code": "generic" })
|
||||||
|
} else {
|
||||||
|
error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn error_code(error: &Value) -> String {
|
||||||
|
error["code"]
|
||||||
|
.as_str()
|
||||||
|
.or_else(|| error.as_str())
|
||||||
|
.unwrap_or("generic")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn active_profile_id(state: &Value, profile: &Value) -> String {
|
||||||
|
profile["id"]
|
||||||
|
.as_str()
|
||||||
|
.or_else(|| state["profile"]["activeProfileId"].as_str())
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or("guest")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn visible_streams(streams: &Value, selected_addon: Option<&str>) -> Value {
|
||||||
|
let Some(selected_addon) = selected_addon
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
else {
|
||||||
|
return streams.clone();
|
||||||
|
};
|
||||||
|
let selected_lower = selected_addon.to_lowercase();
|
||||||
|
let filtered = streams
|
||||||
|
.as_array()
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter(|stream| {
|
||||||
|
stream["addonName"]
|
||||||
|
.as_str()
|
||||||
|
.map(|addon_name| addon_name.trim().to_lowercase() == selected_lower)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
json!(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn value_array_is_empty(value: &Value) -> bool {
|
||||||
|
value.as_array().map(Vec::is_empty).unwrap_or(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn normalize_meta_trailers(meta: &Value) -> Value {
|
||||||
|
let trailers = meta["trailers"]
|
||||||
|
.as_array()
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(normalize_meta_trailer)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
Value::Array(trailers)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_meta_trailer(trailer: &Value) -> Option<Value> {
|
||||||
|
let youtube_id = non_blank_str(trailer, "ytId").or_else(|| {
|
||||||
|
non_blank_str(trailer, "source")
|
||||||
|
.filter(|value| !value.starts_with("http://") && !value.starts_with("https://"))
|
||||||
|
});
|
||||||
|
let url = non_blank_str(trailer, "externalUrl")
|
||||||
|
.or_else(|| non_blank_str(trailer, "url"))
|
||||||
|
.or_else(|| {
|
||||||
|
youtube_id
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| format!("https://www.youtube.com/watch?v={value}"))
|
||||||
|
})?;
|
||||||
|
let id = youtube_id.clone().unwrap_or_else(|| url.clone());
|
||||||
|
let item_type = non_blank_str(trailer, "type").unwrap_or_else(|| "Trailer".to_string());
|
||||||
|
let title = non_blank_str(trailer, "name")
|
||||||
|
.or_else(|| non_blank_str(trailer, "title"))
|
||||||
|
.or_else(|| non_blank_str(trailer, "description"))
|
||||||
|
.unwrap_or_else(|| item_type.clone());
|
||||||
|
let thumbnail = non_blank_str(trailer, "thumbnail").or_else(|| {
|
||||||
|
youtube_id
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| format!("https://i.ytimg.com/vi/{value}/hqdefault.jpg"))
|
||||||
|
});
|
||||||
|
Some(json!({
|
||||||
|
"id": id,
|
||||||
|
"title": title,
|
||||||
|
"type": item_type,
|
||||||
|
"url": url,
|
||||||
|
"thumbnail": thumbnail,
|
||||||
|
"source": "addon"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn non_blank_str(value: &Value, key: &str) -> Option<String> {
|
||||||
|
value[key]
|
||||||
|
.as_str()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn should_sync_watched_state(profile: Option<&Value>, meta: Option<&Value>) -> bool {
|
||||||
|
let Some(meta) = meta else { return false };
|
||||||
|
if meta["id"]
|
||||||
|
.as_str()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(profile) = profile else { return false };
|
||||||
|
let is_guest = profile["isGuest"].as_bool().unwrap_or(false);
|
||||||
|
let has_trakt_token = profile["traktAccessToken"]
|
||||||
|
.as_str()
|
||||||
|
.map(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
!is_guest || has_trakt_token
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn upsert_by_key(target: &mut Value, key: &str, value: &str, item: Value) {
|
||||||
|
if !target.is_array() {
|
||||||
|
*target = json!([]);
|
||||||
|
}
|
||||||
|
let Some(items) = target.as_array_mut() else { return };
|
||||||
|
if let Some(existing) = items
|
||||||
|
.iter_mut()
|
||||||
|
.find(|existing| existing[key].as_str() == Some(value))
|
||||||
|
{
|
||||||
|
*existing = item;
|
||||||
|
} else {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn merge_object(target: &mut Value, patch: Value) {
|
||||||
|
match (target.as_object_mut(), patch) {
|
||||||
|
(Some(target), Value::Object(patch)) => {
|
||||||
|
for (key, value) in patch {
|
||||||
|
target.insert(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
166
src/headless_engine/home.rs
Normal file
166
src/headless_engine/home.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
use super::helpers::{active_profile_id, current_generation, normalize_error};
|
||||||
|
use super::{EffectResultInput, HeadlessEngine};
|
||||||
|
use crate::runtime::{EffectEnvelope, EffectKind};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub(super) fn dispatch_load(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
profile: Option<Value>,
|
||||||
|
language: Option<String>,
|
||||||
|
force: Option<bool>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("homeGeneration");
|
||||||
|
let profile_value = profile.unwrap_or_else(|| engine.state["profile"]["active"].clone());
|
||||||
|
let profile_id = active_profile_id(&engine.state, &profile_value);
|
||||||
|
engine.state["home"] = json!({
|
||||||
|
"isLoading": true,
|
||||||
|
"categories": [],
|
||||||
|
"continueWatching": [],
|
||||||
|
"userAddons": [],
|
||||||
|
"metadataFeeds": [],
|
||||||
|
"billboard": Value::Null,
|
||||||
|
"error": Value::Null,
|
||||||
|
"generation": generation
|
||||||
|
});
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::ReadHomeBootstrap,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"profileId": profile_id,
|
||||||
|
"profile": profile_value,
|
||||||
|
"language": language.unwrap_or_else(|| "en".to_string()),
|
||||||
|
"force": force.unwrap_or(false)
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_direct_playback(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
meta: Value,
|
||||||
|
language: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("playbackPrepGeneration");
|
||||||
|
engine.state["home"]["isDirectLoading"] = json!(true);
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::PrepareDirectPlayback,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"meta": meta,
|
||||||
|
"language": language.unwrap_or_else(|| "en".to_string()),
|
||||||
|
"profile": profile.unwrap_or(Value::Null)
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_catalog_page(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
category_id: String,
|
||||||
|
transport_url: Option<String>,
|
||||||
|
content_type: String,
|
||||||
|
catalog_id: String,
|
||||||
|
skip: Option<i32>,
|
||||||
|
genre: Option<String>,
|
||||||
|
search: Option<String>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("homeGeneration");
|
||||||
|
engine.state["home"]["paging"] = json!({
|
||||||
|
"categoryId": category_id,
|
||||||
|
"isLoading": true,
|
||||||
|
"error": Value::Null
|
||||||
|
});
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::FetchCatalogPage,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"categoryId": engine.state["home"]["paging"]["categoryId"].clone(),
|
||||||
|
"transportUrl": transport_url,
|
||||||
|
"contentType": content_type,
|
||||||
|
"catalogId": catalog_id,
|
||||||
|
"skip": skip.unwrap_or(0).max(0),
|
||||||
|
"genre": genre,
|
||||||
|
"search": search
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn complete(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
effect_type: &str,
|
||||||
|
generation: u64,
|
||||||
|
result: &EffectResultInput,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
match effect_type {
|
||||||
|
"readHomeBootstrap" => {
|
||||||
|
if generation == current_generation(&engine.state, "homeGeneration") {
|
||||||
|
engine.state["home"]["isLoading"] = json!(false);
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["home"]["categories"] = result
|
||||||
|
.value
|
||||||
|
.get("categories")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
engine.state["home"]["continueWatching"] = result
|
||||||
|
.value
|
||||||
|
.get("continueWatching")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
engine.state["home"]["watchlist"] = result
|
||||||
|
.value
|
||||||
|
.get("watchlist")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
engine.state["home"]["userAddons"] = result
|
||||||
|
.value
|
||||||
|
.get("userAddons")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
engine.state["home"]["metadataFeeds"] = result
|
||||||
|
.value
|
||||||
|
.get("metadataFeeds")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
engine.state["home"]["billboard"] = result
|
||||||
|
.value
|
||||||
|
.get("billboard")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
engine.state["home"]["error"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["home"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"prepareDirectPlayback" => {
|
||||||
|
if generation == current_generation(&engine.state, "playbackPrepGeneration") {
|
||||||
|
engine.state["home"]["isDirectLoading"] = json!(false);
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["player"]["directPlaybackTarget"] = result.value.clone();
|
||||||
|
engine.state["player"]["playerError"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["player"]["directPlaybackTarget"] = Value::Null;
|
||||||
|
engine.state["player"]["playerError"] =
|
||||||
|
json!(super::helpers::error_code(&result.error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"fetchCatalogPage" => {
|
||||||
|
if generation == current_generation(&engine.state, "homeGeneration") {
|
||||||
|
engine.state["home"]["paging"]["isLoading"] = json!(false);
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["home"]["paging"]["items"] = result
|
||||||
|
.value
|
||||||
|
.get("items")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| result.value.clone());
|
||||||
|
engine.state["home"]["paging"]["error"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["home"]["paging"]["error"] =
|
||||||
|
normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
302
src/headless_engine/library.rs
Normal file
302
src/headless_engine/library.rs
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
use super::helpers::{active_profile_id, current_generation, normalize_error, should_sync_watched_state};
|
||||||
|
use super::{EffectResultInput, HeadlessEngine};
|
||||||
|
use crate::runtime::{EffectEnvelope, EffectKind};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub(super) fn dispatch_profile_activated(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
profile: Value,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let profile_id = profile["id"]
|
||||||
|
.as_str()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or("guest")
|
||||||
|
.to_string();
|
||||||
|
engine.state["profile"]["active"] = profile.clone();
|
||||||
|
engine.state["profile"]["activeProfileId"] = json!(profile_id.clone());
|
||||||
|
engine.state["library"]["activeProfileId"] = json!(profile_id);
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_hydrate(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
profile_id: Option<String>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("libraryGeneration");
|
||||||
|
let resolved_profile_id =
|
||||||
|
profile_id.unwrap_or_else(|| active_profile_id(&engine.state, &Value::Null));
|
||||||
|
engine.state["library"]["activeProfileId"] = json!(resolved_profile_id.clone());
|
||||||
|
engine.state["library"]["isLoading"] = json!(true);
|
||||||
|
engine.state["library"]["error"] = Value::Null;
|
||||||
|
engine.state["library"]["generation"] = json!(generation);
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::ReadLibraryState,
|
||||||
|
generation,
|
||||||
|
json!({ "profileId": resolved_profile_id }),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_toggle_watchlist(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
item: Value,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("libraryGeneration");
|
||||||
|
let profile_id = active_profile_id(&engine.state, &Value::Null);
|
||||||
|
engine.state["library"]["lastCommand"] = json!({
|
||||||
|
"type": "toggleWatchlist",
|
||||||
|
"item": item
|
||||||
|
});
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::WriteLibraryCommand,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"profileId": profile_id,
|
||||||
|
"command": engine.state["library"]["lastCommand"].clone()
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_set_feedback(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
id: String,
|
||||||
|
value: Option<bool>,
|
||||||
|
meta: Value,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("libraryGeneration");
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::WriteFeedback,
|
||||||
|
generation,
|
||||||
|
json!({ "id": id, "value": value, "meta": meta }),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_clear_progress(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
profile: Option<Value>,
|
||||||
|
meta: Value,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("libraryGeneration");
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::ClearPlaybackProgress,
|
||||||
|
generation,
|
||||||
|
json!({ "profile": profile.unwrap_or(Value::Null), "meta": meta }),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn dispatch_save_progress(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
profile: Option<Value>,
|
||||||
|
meta: Value,
|
||||||
|
time_offset: i64,
|
||||||
|
duration: i64,
|
||||||
|
last_video_id: Option<String>,
|
||||||
|
last_stream_index: Option<i32>,
|
||||||
|
last_episode_name: Option<String>,
|
||||||
|
last_episode_season: Option<i64>,
|
||||||
|
last_episode_number: Option<i64>,
|
||||||
|
last_episode_thumbnail: Option<String>,
|
||||||
|
last_stream_url: Option<String>,
|
||||||
|
last_stream_title: Option<String>,
|
||||||
|
last_audio_language: Option<String>,
|
||||||
|
last_subtitle_language: Option<String>,
|
||||||
|
scrobble_trakt_pause: Option<bool>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("libraryGeneration");
|
||||||
|
let profile_id = active_profile_id(&engine.state, &Value::Null);
|
||||||
|
let progress = json!({
|
||||||
|
"meta": meta,
|
||||||
|
"timeOffset": time_offset.max(0),
|
||||||
|
"duration": duration.max(0),
|
||||||
|
"lastVideoId": last_video_id,
|
||||||
|
"lastStreamIndex": last_stream_index,
|
||||||
|
"lastEpisodeName": last_episode_name,
|
||||||
|
"lastEpisodeSeason": last_episode_season,
|
||||||
|
"lastEpisodeNumber": last_episode_number,
|
||||||
|
"lastEpisodeThumbnail": last_episode_thumbnail,
|
||||||
|
"lastStreamUrl": last_stream_url,
|
||||||
|
"lastStreamTitle": last_stream_title,
|
||||||
|
"lastAudioLanguage": last_audio_language,
|
||||||
|
"lastSubtitleLanguage": last_subtitle_language
|
||||||
|
});
|
||||||
|
engine.state["library"]["pendingPlaybackProgress"] = progress.clone();
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::WritePlaybackProgress,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"profileId": profile_id,
|
||||||
|
"profile": profile.unwrap_or(Value::Null),
|
||||||
|
"scrobbleTraktPause": scrobble_trakt_pause.unwrap_or(true),
|
||||||
|
"progress": progress
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_mark_watched(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
series_id: String,
|
||||||
|
video_ids: Vec<String>,
|
||||||
|
watched: Option<bool>,
|
||||||
|
meta: Option<Value>,
|
||||||
|
episodes: Option<Vec<Value>>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("libraryGeneration");
|
||||||
|
let profile_id = active_profile_id(&engine.state, &Value::Null);
|
||||||
|
let watched_value = watched.unwrap_or(true);
|
||||||
|
let clean_video_ids: Vec<String> = video_ids
|
||||||
|
.into_iter()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.fold(Vec::new(), |mut acc, value| {
|
||||||
|
if !acc.contains(&value) {
|
||||||
|
acc.push(value);
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
});
|
||||||
|
engine.state["library"]["lastCommand"] = json!({
|
||||||
|
"type": "markWatched",
|
||||||
|
"seriesId": series_id,
|
||||||
|
"videoIds": clean_video_ids,
|
||||||
|
"watched": watched_value
|
||||||
|
});
|
||||||
|
let mut effects = vec![engine.effect(
|
||||||
|
EffectKind::WriteLibraryCommand,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"profileId": profile_id,
|
||||||
|
"command": engine.state["library"]["lastCommand"].clone()
|
||||||
|
}),
|
||||||
|
)];
|
||||||
|
if should_sync_watched_state(profile.as_ref(), meta.as_ref()) {
|
||||||
|
effects.push(engine.effect(
|
||||||
|
EffectKind::SyncWatchedState,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"profile": profile.unwrap_or(Value::Null),
|
||||||
|
"meta": meta.unwrap_or(Value::Null),
|
||||||
|
"episodes": episodes.unwrap_or_default(),
|
||||||
|
"watched": watched_value
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
effects
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn complete(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
effect_type: &str,
|
||||||
|
generation: u64,
|
||||||
|
result: &EffectResultInput,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
match effect_type {
|
||||||
|
"readLibraryState" => {
|
||||||
|
if generation == current_generation(&engine.state, "libraryGeneration") {
|
||||||
|
engine.state["library"]["isLoading"] = json!(false);
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["library"]["watchlist"] = result
|
||||||
|
.value
|
||||||
|
.get("watchlist")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
engine.state["library"]["continueWatching"] = result
|
||||||
|
.value
|
||||||
|
.get("continueWatching")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
engine.state["library"]["liked"] = result
|
||||||
|
.value
|
||||||
|
.get("liked")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
engine.state["library"]["watched"] = result
|
||||||
|
.value
|
||||||
|
.get("watched")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!({}));
|
||||||
|
engine.state["library"]["error"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["library"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"writeLibraryCommand" => {
|
||||||
|
if generation == current_generation(&engine.state, "libraryGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["library"]["lastWrite"] = result.value.clone();
|
||||||
|
engine.state["library"]["lastWriteError"] = Value::Null;
|
||||||
|
if let Some(value) =
|
||||||
|
engine.state["library"]["lastWrite"].get("isInWatchlist")
|
||||||
|
{
|
||||||
|
engine.state["detail"]["isInWatchlist"] = value.clone();
|
||||||
|
}
|
||||||
|
if let Some(value) =
|
||||||
|
engine.state["library"]["lastWrite"].get("localWatchedVideoIds")
|
||||||
|
{
|
||||||
|
engine.state["detail"]["localWatchedVideoIds"] = value.clone();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
engine.state["library"]["lastWriteError"] =
|
||||||
|
normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"writeFeedback" => {
|
||||||
|
if generation == current_generation(&engine.state, "libraryGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["detail"]["feedback"] =
|
||||||
|
result.value.get("feedback").cloned().unwrap_or(Value::Null);
|
||||||
|
engine.state["library"]["lastWriteError"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["library"]["lastWriteError"] =
|
||||||
|
normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"clearPlaybackProgress" => {
|
||||||
|
if generation == current_generation(&engine.state, "libraryGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["detail"]["savedPlayback"] = Value::Null;
|
||||||
|
engine.state["library"]["lastWriteError"] = Value::Null;
|
||||||
|
// Remove the dropped item from home.continueWatching so stale state
|
||||||
|
// doesn't reappear when the user navigates back to the home screen.
|
||||||
|
if let Some(dropped_id) = result.value.get("droppedId").and_then(Value::as_str) {
|
||||||
|
if let Some(cw) = engine.state["home"]["continueWatching"].as_array_mut() {
|
||||||
|
cw.retain(|item| {
|
||||||
|
item.get("id").and_then(Value::as_str) != Some(dropped_id)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
engine.state["library"]["lastWriteError"] =
|
||||||
|
normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"writePlaybackProgress" => {
|
||||||
|
if generation == current_generation(&engine.state, "libraryGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["library"]["savedPlaybackProgress"] =
|
||||||
|
engine.state["library"]["pendingPlaybackProgress"].clone();
|
||||||
|
engine.state["library"]["pendingPlaybackProgress"] = Value::Null;
|
||||||
|
engine.state["library"]["lastWriteError"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["library"]["lastWriteError"] =
|
||||||
|
normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"syncWatchedState" => {
|
||||||
|
if generation == current_generation(&engine.state, "libraryGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["library"]["lastWatchedSync"] = result.value.clone();
|
||||||
|
engine.state["library"]["lastWatchedSyncError"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["library"]["lastWatchedSyncError"] =
|
||||||
|
normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
1351
src/headless_engine/mod.rs
Normal file
1351
src/headless_engine/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
11
src/headless_engine/navigation.rs
Normal file
11
src/headless_engine/navigation.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
use super::HeadlessEngine;
|
||||||
|
use crate::runtime::EffectEnvelope;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub(super) fn dispatch(engine: &mut HeadlessEngine, route: String, params: Option<Value>) -> Vec<EffectEnvelope> {
|
||||||
|
engine.state["navigation"] = json!({
|
||||||
|
"route": route,
|
||||||
|
"params": params.unwrap_or(Value::Null)
|
||||||
|
});
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
39
src/headless_engine/offline.rs
Normal file
39
src/headless_engine/offline.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
use super::helpers::{current_generation, normalize_error};
|
||||||
|
use super::{EffectResultInput, HeadlessEngine};
|
||||||
|
use crate::runtime::{EffectEnvelope, EffectKind};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub(super) fn dispatch(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
meta: Value,
|
||||||
|
stream: Value,
|
||||||
|
video_id: Option<String>,
|
||||||
|
video: Option<Value>,
|
||||||
|
subtitle: Option<Value>,
|
||||||
|
profile_id: Option<String>,
|
||||||
|
language: Option<String>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("offlineGeneration");
|
||||||
|
engine.state["offline"]["lastRequest"] = json!({
|
||||||
|
"meta": meta,
|
||||||
|
"stream": stream,
|
||||||
|
"videoId": video_id,
|
||||||
|
"video": video,
|
||||||
|
"subtitle": subtitle,
|
||||||
|
"profileId": profile_id,
|
||||||
|
"language": language
|
||||||
|
});
|
||||||
|
vec![engine.effect(EffectKind::EnqueueOfflineDownload, generation, engine.state["offline"]["lastRequest"].clone())]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn complete(engine: &mut HeadlessEngine, generation: u64, result: &EffectResultInput) -> Vec<EffectEnvelope> {
|
||||||
|
if generation == current_generation(&engine.state, "offlineGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["offline"]["lastEnqueued"] = result.value.clone();
|
||||||
|
engine.state["offline"]["error"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["offline"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
453
src/headless_engine/player.rs
Normal file
453
src/headless_engine/player.rs
Normal file
|
|
@ -0,0 +1,453 @@
|
||||||
|
use super::helpers::{current_generation, error_code, normalize_error};
|
||||||
|
use super::{EffectResultInput, HeadlessEngine};
|
||||||
|
use crate::runtime::{EffectEnvelope, EffectKind};
|
||||||
|
use crate::{player_flow, stream_policy};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn dispatch_next_episode_prefetch(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
content_type: String,
|
||||||
|
series_id: String,
|
||||||
|
next_video_id: String,
|
||||||
|
title: Option<String>,
|
||||||
|
original_name: Option<String>,
|
||||||
|
year: Option<i32>,
|
||||||
|
language: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let already_prefetching = engine.state["player"]["prefetchingNextVideoId"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|v| v == next_video_id);
|
||||||
|
let already_cached = engine.state["player"]["prefetchedNextEpisode"]["videoId"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|v| v == next_video_id);
|
||||||
|
if already_prefetching || already_cached {
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
let generation = current_generation(&engine.state, "playerGeneration");
|
||||||
|
engine.state["player"]["prefetchingNextVideoId"] = json!(next_video_id);
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::PrefetchNextEpisodeStreams,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"contentType": content_type,
|
||||||
|
"seriesId": series_id,
|
||||||
|
"nextVideoId": next_video_id,
|
||||||
|
"title": title.unwrap_or_default(),
|
||||||
|
"originalName": original_name,
|
||||||
|
"year": year,
|
||||||
|
"language": language.unwrap_or_else(|| "en".to_string()),
|
||||||
|
"profile": profile.unwrap_or(Value::Null)
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn dispatch_load_streams(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
current_video_id: Option<String>,
|
||||||
|
initial_video_id: Option<String>,
|
||||||
|
initial_streams: Option<Vec<Value>>,
|
||||||
|
initial_stream_index: Option<i32>,
|
||||||
|
saved_url: Option<String>,
|
||||||
|
saved_title: Option<String>,
|
||||||
|
source_selection_mode: Option<String>,
|
||||||
|
regex_pattern: Option<String>,
|
||||||
|
preferred_binge_group: Option<String>,
|
||||||
|
title: Option<String>,
|
||||||
|
original_name: Option<String>,
|
||||||
|
year: Option<i32>,
|
||||||
|
language: Option<String>,
|
||||||
|
profile: Option<Value>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("playerGeneration");
|
||||||
|
let mut initial_streams = initial_streams.unwrap_or_default();
|
||||||
|
let initial_stream_index = initial_stream_index.unwrap_or(0);
|
||||||
|
|
||||||
|
// If no initial streams were provided by the caller but we have a prefetch
|
||||||
|
// cache hit for this video_id, inject those streams so playback can start
|
||||||
|
// without waiting for a fresh fetch.
|
||||||
|
let mut effective_initial_video_id = initial_video_id.clone();
|
||||||
|
if initial_streams.is_empty() {
|
||||||
|
let prefetched = &engine.state["player"]["prefetchedNextEpisode"];
|
||||||
|
let cached_video_id = prefetched["videoId"].as_str().map(str::to_string);
|
||||||
|
if cached_video_id.is_some() && cached_video_id == current_video_id {
|
||||||
|
initial_streams = prefetched["streams"]
|
||||||
|
.as_array()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
effective_initial_video_id = cached_video_id;
|
||||||
|
engine.state["player"]["prefetchedNextEpisode"] = Value::Null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let player_flow_state = engine.state.get("player").cloned().unwrap_or_else(|| json!({}));
|
||||||
|
let Some(flow) = player_flow::player_flow_dispatch_json(
|
||||||
|
&player_flow_state.to_string(),
|
||||||
|
&json!({
|
||||||
|
"type": "loadStreamsRequested",
|
||||||
|
"contentType": content_type,
|
||||||
|
"id": id,
|
||||||
|
"currentVideoId": current_video_id,
|
||||||
|
"initialVideoId": effective_initial_video_id,
|
||||||
|
"initialStreams": initial_streams,
|
||||||
|
"initialStreamIndex": initial_stream_index
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
) else {
|
||||||
|
engine.state["player"]["playerError"] = json!("invalid_flow_action");
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let Ok(flow) = serde_json::from_str::<Value>(&flow) else {
|
||||||
|
engine.state["player"]["playerError"] = json!("invalid_flow_result");
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
engine.state["player"] = flow["state"].clone();
|
||||||
|
engine.state["player"]["pendingStreamLoad"] = json!({
|
||||||
|
"savedUrl": saved_url,
|
||||||
|
"savedTitle": saved_title,
|
||||||
|
"sourceSelectionMode": source_selection_mode.unwrap_or_else(|| "manual".to_string()),
|
||||||
|
"regexPattern": regex_pattern,
|
||||||
|
"preferredBingeGroup": preferred_binge_group,
|
||||||
|
"initialStreams": initial_streams,
|
||||||
|
"initialStreamIndex": initial_stream_index,
|
||||||
|
"currentVideoId": current_video_id,
|
||||||
|
"title": title,
|
||||||
|
"originalName": original_name,
|
||||||
|
"year": year,
|
||||||
|
"language": language.unwrap_or_else(|| "en".to_string()),
|
||||||
|
"profile": profile.unwrap_or(Value::Null)
|
||||||
|
});
|
||||||
|
flow["effects"]
|
||||||
|
.as_array()
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.map(|effect| {
|
||||||
|
let mut payload = effect.clone();
|
||||||
|
if effect["type"].as_str() == Some("loadStreams") {
|
||||||
|
payload["initialStreams"] =
|
||||||
|
engine.state["player"]["pendingStreamLoad"]["initialStreams"].clone();
|
||||||
|
payload["title"] =
|
||||||
|
engine.state["player"]["pendingStreamLoad"]["title"].clone();
|
||||||
|
payload["originalName"] =
|
||||||
|
engine.state["player"]["pendingStreamLoad"]["originalName"].clone();
|
||||||
|
payload["year"] =
|
||||||
|
engine.state["player"]["pendingStreamLoad"]["year"].clone();
|
||||||
|
payload["language"] =
|
||||||
|
engine.state["player"]["pendingStreamLoad"]["language"].clone();
|
||||||
|
payload["profile"] =
|
||||||
|
engine.state["player"]["pendingStreamLoad"]["profile"].clone();
|
||||||
|
}
|
||||||
|
engine.effect_raw(effect["type"].as_str().unwrap_or("unknown"), generation, payload)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_streams_loaded(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
streams: Vec<Value>,
|
||||||
|
current_video_id: Option<String>,
|
||||||
|
initial_stream_index: Option<i32>,
|
||||||
|
saved_url: Option<String>,
|
||||||
|
saved_title: Option<String>,
|
||||||
|
source_selection_mode: Option<String>,
|
||||||
|
regex_pattern: Option<String>,
|
||||||
|
preferred_binge_group: Option<String>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = current_generation(&engine.state, "playerGeneration");
|
||||||
|
let player_flow_state = engine.state.get("player").cloned().unwrap_or_else(|| json!({}));
|
||||||
|
let Some(flow) = player_flow::player_flow_dispatch_json(
|
||||||
|
&player_flow_state.to_string(),
|
||||||
|
&json!({
|
||||||
|
"type": "streamsLoaded",
|
||||||
|
"streams": streams,
|
||||||
|
"currentVideoId": current_video_id,
|
||||||
|
"initialStreamIndex": initial_stream_index.unwrap_or(0),
|
||||||
|
"savedUrl": saved_url,
|
||||||
|
"savedTitle": saved_title,
|
||||||
|
"sourceSelectionMode": source_selection_mode,
|
||||||
|
"regexPattern": regex_pattern,
|
||||||
|
"preferredBingeGroup": preferred_binge_group
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
) else {
|
||||||
|
engine.state["player"]["playerError"] = json!("invalid_flow_action");
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let Ok(flow) = serde_json::from_str::<Value>(&flow) else {
|
||||||
|
engine.state["player"]["playerError"] = json!("invalid_flow_result");
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
engine.state["player"] = flow["state"].clone();
|
||||||
|
engine.state["player"]["generation"] = json!(generation);
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_streams_failed(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
err_code: Option<String>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let player_flow_state = engine.state.get("player").cloned().unwrap_or_else(|| json!({}));
|
||||||
|
let Some(flow) = player_flow::player_flow_dispatch_json(
|
||||||
|
&player_flow_state.to_string(),
|
||||||
|
&json!({ "type": "streamsFailed", "errorCode": err_code }).to_string(),
|
||||||
|
) else {
|
||||||
|
engine.state["player"]["playerError"] = json!("invalid_flow_action");
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
let Ok(flow) = serde_json::from_str::<Value>(&flow) else {
|
||||||
|
engine.state["player"]["playerError"] = json!("invalid_flow_result");
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
engine.state["player"] = flow["state"].clone();
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_resolve_playback(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
url: String,
|
||||||
|
stream: Option<Value>,
|
||||||
|
current_video_id: Option<String>,
|
||||||
|
title: Option<String>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("playerGeneration");
|
||||||
|
engine.state["player"]["currentUrl"] = json!(url);
|
||||||
|
engine.state["player"]["resolvedUrl"] = Value::Null;
|
||||||
|
engine.state["player"]["isBuffering"] = json!(true);
|
||||||
|
engine.state["player"]["hasStartedPlaying"] = json!(false);
|
||||||
|
engine.state["player"]["isVideoRendered"] = json!(false);
|
||||||
|
engine.state["player"]["playerError"] = Value::Null;
|
||||||
|
if stream_policy::is_torrent_playback_url(&url) {
|
||||||
|
let stream_value = stream.unwrap_or(Value::Null);
|
||||||
|
let file_idx = stream_value["fileIdx"].as_i64();
|
||||||
|
let preferred_filename = stream_value["effectiveFilename"]
|
||||||
|
.as_str()
|
||||||
|
.map(ToString::to_string);
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::StartTorrentStream,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"url": url,
|
||||||
|
"stream": stream_value.clone(),
|
||||||
|
"currentVideoId": current_video_id,
|
||||||
|
"title": title.unwrap_or_else(|| "Fluxa".to_string()),
|
||||||
|
"fileIdx": file_idx,
|
||||||
|
"preferredFilename": preferred_filename,
|
||||||
|
"sources": stream_value["sources"].as_array().cloned().unwrap_or_default()
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
} else {
|
||||||
|
engine.state["player"]["resolvedUrl"] = engine.state["player"]["currentUrl"].clone();
|
||||||
|
engine.state["player"]["isBuffering"] = json!(false);
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::StopTorrent,
|
||||||
|
generation,
|
||||||
|
json!({ "reason": "directPlayback" }),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_scrobble(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
token: String,
|
||||||
|
meta_type: String,
|
||||||
|
item_id: String,
|
||||||
|
progress: f64,
|
||||||
|
action_name: String,
|
||||||
|
profile: Option<Value>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("playerGeneration");
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::EnqueueTraktScrobble,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"token": token,
|
||||||
|
"metaType": meta_type,
|
||||||
|
"itemId": item_id,
|
||||||
|
"progress": progress,
|
||||||
|
"actionName": action_name,
|
||||||
|
"profile": profile.unwrap_or(Value::Null)
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_intro_segments(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
imdb_id: String,
|
||||||
|
season: i32,
|
||||||
|
episode: i32,
|
||||||
|
title: Option<String>,
|
||||||
|
use_intro_db: bool,
|
||||||
|
use_ani_skip: bool,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("introGeneration");
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::FetchIntroSegments,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"imdbId": imdb_id,
|
||||||
|
"season": season,
|
||||||
|
"episode": episode,
|
||||||
|
"title": title,
|
||||||
|
"useIntroDb": use_intro_db,
|
||||||
|
"useAniSkip": use_ani_skip
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
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("introGeneration");
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::ResolveIntroImdbId,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"meta": meta,
|
||||||
|
"videoId": video_id,
|
||||||
|
"language": language.unwrap_or_else(|| "en".to_string())
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_subtitle_load(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
stream: Value,
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
extra_args: Option<String>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("playerGeneration");
|
||||||
|
engine.state["player"]["subtitleLoading"] = json!(true);
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::FetchSubtitles,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"stream": stream,
|
||||||
|
"contentType": content_type,
|
||||||
|
"id": id,
|
||||||
|
"extraArgs": extra_args.unwrap_or_default()
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn complete(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
effect_type: &str,
|
||||||
|
generation: u64,
|
||||||
|
result: &EffectResultInput,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
match effect_type {
|
||||||
|
"loadStreams" => {
|
||||||
|
if generation == current_generation(&engine.state, "playerGeneration") {
|
||||||
|
let pending = engine.state["player"]["pendingStreamLoad"].clone();
|
||||||
|
if result.status == "ok" {
|
||||||
|
dispatch_streams_loaded(
|
||||||
|
engine,
|
||||||
|
result.value.as_array().cloned().unwrap_or_default(),
|
||||||
|
pending["currentVideoId"].as_str().map(ToString::to_string),
|
||||||
|
Some(pending["initialStreamIndex"].as_i64().unwrap_or(0) as i32),
|
||||||
|
pending["savedUrl"].as_str().map(ToString::to_string),
|
||||||
|
pending["savedTitle"].as_str().map(ToString::to_string),
|
||||||
|
pending["sourceSelectionMode"].as_str().map(ToString::to_string),
|
||||||
|
pending["regexPattern"].as_str().map(ToString::to_string),
|
||||||
|
pending["preferredBingeGroup"].as_str().map(ToString::to_string),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dispatch_streams_failed(engine, Some(error_code(&result.error)));
|
||||||
|
}
|
||||||
|
engine.state["player"]["pendingStreamLoad"] = Value::Null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"startTorrentStream" => {
|
||||||
|
if generation == current_generation(&engine.state, "playerGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["player"]["resolvedUrl"] =
|
||||||
|
result.value.get("url").cloned().unwrap_or(Value::Null);
|
||||||
|
engine.state["player"]["isBuffering"] = json!(false);
|
||||||
|
engine.state["player"]["playerError"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["player"]["resolvedUrl"] = Value::Null;
|
||||||
|
engine.state["player"]["isBuffering"] = json!(false);
|
||||||
|
engine.state["player"]["playerError"] = json!(error_code(&result.error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"enqueueTraktScrobble" => {
|
||||||
|
if generation == current_generation(&engine.state, "playerGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["player"]["lastScrobble"] = result.value.clone();
|
||||||
|
engine.state["player"]["playerError"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["player"]["playerError"] = json!(error_code(&result.error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"stopTorrent" => {
|
||||||
|
if generation == current_generation(&engine.state, "playerGeneration")
|
||||||
|
&& result.status != "ok"
|
||||||
|
{
|
||||||
|
engine.state["player"]["stopTorrentWarning"] =
|
||||||
|
normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"fetchIntroSegments" => {
|
||||||
|
if generation == current_generation(&engine.state, "introGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["player"]["introSegments"] = result.value.clone();
|
||||||
|
engine.state["player"]["playerError"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["player"]["introSegments"] = json!([]);
|
||||||
|
engine.state["player"]["playerError"] = json!(error_code(&result.error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"resolveIntroImdbId" => {
|
||||||
|
if generation == current_generation(&engine.state, "introGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["player"]["introImdbId"] = result.value.clone();
|
||||||
|
engine.state["player"]["playerError"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["player"]["introImdbId"] = Value::Null;
|
||||||
|
engine.state["player"]["playerError"] = json!(error_code(&result.error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"fetchSubtitles" => {
|
||||||
|
if generation == current_generation(&engine.state, "playerGeneration") {
|
||||||
|
engine.state["player"]["subtitleLoading"] = json!(false);
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["player"]["subtitles"] = result
|
||||||
|
.value
|
||||||
|
.get("subtitles")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| result.value.clone());
|
||||||
|
engine.state["player"]["playerError"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["player"]["playerError"] = json!(error_code(&result.error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"prefetchNextEpisodeStreams" => {
|
||||||
|
// Only accept if the prefetch generation is still current (player hasn't moved on).
|
||||||
|
if generation == current_generation(&engine.state, "playerGeneration") {
|
||||||
|
if result.status == "ok" {
|
||||||
|
let prefetched_video_id = engine.state["player"]["prefetchingNextVideoId"].clone();
|
||||||
|
engine.state["player"]["prefetchedNextEpisode"] = json!({
|
||||||
|
"videoId": prefetched_video_id,
|
||||||
|
"streams": result.value.get("streams").cloned().unwrap_or_else(|| json!([]))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
engine.state["player"]["prefetchingNextVideoId"] = Value::Null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
55
src/headless_engine/search.rs
Normal file
55
src/headless_engine/search.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
use super::helpers::{active_profile_id, current_generation, normalize_error};
|
||||||
|
use super::{EffectResultInput, HeadlessEngine};
|
||||||
|
use crate::runtime::{EffectEnvelope, EffectKind};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub(super) fn dispatch(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
query: String,
|
||||||
|
profile: Option<Value>,
|
||||||
|
language: Option<String>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("searchGeneration");
|
||||||
|
let profile_value = profile.unwrap_or_else(|| engine.state["profile"]["active"].clone());
|
||||||
|
engine.state["search"] = json!({
|
||||||
|
"query": query,
|
||||||
|
"isLoading": true,
|
||||||
|
"results": [],
|
||||||
|
"error": Value::Null,
|
||||||
|
"generation": generation
|
||||||
|
});
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::RunSearch,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"query": engine.state["search"]["query"].clone(),
|
||||||
|
"profileId": active_profile_id(&engine.state, &profile_value),
|
||||||
|
"profile": profile_value,
|
||||||
|
"language": language.unwrap_or_else(|| "en".to_string())
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn complete(engine: &mut HeadlessEngine, generation: u64, result: &EffectResultInput) -> Vec<EffectEnvelope> {
|
||||||
|
if generation == current_generation(&engine.state, "searchGeneration") {
|
||||||
|
engine.state["search"]["isLoading"] = json!(false);
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["search"]["results"] = result
|
||||||
|
.value
|
||||||
|
.get("results")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| result.value.clone());
|
||||||
|
engine.state["search"]["categories"] = result
|
||||||
|
.value
|
||||||
|
.get("categories")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
engine.state["search"]["grouping"] =
|
||||||
|
result.value.get("grouping").cloned().unwrap_or(Value::Null);
|
||||||
|
engine.state["search"]["error"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["search"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
21
src/headless_engine/settings.rs
Normal file
21
src/headless_engine/settings.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
use super::helpers::{current_generation, normalize_error};
|
||||||
|
use super::{EffectResultInput, HeadlessEngine};
|
||||||
|
use crate::runtime::{EffectEnvelope, EffectKind};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub(super) fn dispatch(engine: &mut HeadlessEngine, key: String, value: Value) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("settingsGeneration");
|
||||||
|
engine.state["settings"]["values"][key.as_str()] = value.clone();
|
||||||
|
vec![engine.effect(EffectKind::WriteSettings, generation, json!({ "key": key, "value": value }))]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn complete(engine: &mut HeadlessEngine, generation: u64, result: &EffectResultInput) -> Vec<EffectEnvelope> {
|
||||||
|
if generation == current_generation(&engine.state, "settingsGeneration") {
|
||||||
|
if result.status != "ok" {
|
||||||
|
engine.state["settings"]["lastWriteError"] = normalize_error(result.error.clone());
|
||||||
|
} else {
|
||||||
|
engine.state["settings"]["lastWriteError"] = Value::Null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
40
src/headless_engine/state.rs
Normal file
40
src/headless_engine/state.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub(super) fn default_state() -> Value {
|
||||||
|
json!({
|
||||||
|
"navigation": { "route": "home", "params": Value::Null },
|
||||||
|
"home": {},
|
||||||
|
"search": {},
|
||||||
|
"discover": {},
|
||||||
|
"detail": {},
|
||||||
|
"player": {},
|
||||||
|
"library": {},
|
||||||
|
"profile": {},
|
||||||
|
"settings": {},
|
||||||
|
"calendar": {},
|
||||||
|
"addons": { "installed": [] },
|
||||||
|
"auth": {},
|
||||||
|
"sync": {},
|
||||||
|
"lookup": {},
|
||||||
|
"offline": {},
|
||||||
|
"pendingEffects": [],
|
||||||
|
"_runtime": {
|
||||||
|
"detailGeneration": 0,
|
||||||
|
"playerGeneration": 0,
|
||||||
|
"homeGeneration": 0,
|
||||||
|
"libraryGeneration": 0,
|
||||||
|
"addonGeneration": 0,
|
||||||
|
"searchGeneration": 0,
|
||||||
|
"discoverGeneration": 0,
|
||||||
|
"syncGeneration": 0,
|
||||||
|
"authGeneration": 0,
|
||||||
|
"settingsGeneration": 0,
|
||||||
|
"calendarGeneration": 0,
|
||||||
|
"offlineGeneration": 0,
|
||||||
|
"detailStreamsGeneration": 0,
|
||||||
|
"lookupGeneration": 0,
|
||||||
|
"playbackPrepGeneration": 0,
|
||||||
|
"introGeneration": 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
100
src/headless_engine/sync.rs
Normal file
100
src/headless_engine/sync.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
use super::helpers::{active_profile_id, current_generation, normalize_error};
|
||||||
|
use super::{EffectResultInput, HeadlessEngine};
|
||||||
|
use crate::runtime::{EffectEnvelope, EffectKind};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub(super) fn dispatch_external_sync(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
provider: String,
|
||||||
|
profile: Option<Value>,
|
||||||
|
language: Option<String>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("syncGeneration");
|
||||||
|
let profile_value = profile.unwrap_or_else(|| engine.state["profile"]["active"].clone());
|
||||||
|
engine.state["sync"] = json!({
|
||||||
|
"provider": provider,
|
||||||
|
"isLoading": true,
|
||||||
|
"error": Value::Null,
|
||||||
|
"generation": generation
|
||||||
|
});
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::RunExternalSync,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"provider": engine.state["sync"]["provider"].clone(),
|
||||||
|
"profileId": active_profile_id(&engine.state, &profile_value),
|
||||||
|
"profile": profile_value,
|
||||||
|
"language": language.unwrap_or_else(|| "en".to_string())
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn dispatch_integration_sync(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
provider: String,
|
||||||
|
profile: Value,
|
||||||
|
language: Option<String>,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
let generation = engine.bump_generation("syncGeneration");
|
||||||
|
engine.state["sync"] = json!({
|
||||||
|
"provider": provider,
|
||||||
|
"isLoading": true,
|
||||||
|
"error": Value::Null,
|
||||||
|
"generation": generation
|
||||||
|
});
|
||||||
|
vec![engine.effect(
|
||||||
|
EffectKind::SyncExternalIntegration,
|
||||||
|
generation,
|
||||||
|
json!({
|
||||||
|
"provider": engine.state["sync"]["provider"].clone(),
|
||||||
|
"profile": profile,
|
||||||
|
"language": language.unwrap_or_else(|| "en".to_string())
|
||||||
|
}),
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn complete(
|
||||||
|
engine: &mut HeadlessEngine,
|
||||||
|
effect_type: &str,
|
||||||
|
generation: u64,
|
||||||
|
result: &EffectResultInput,
|
||||||
|
) -> Vec<EffectEnvelope> {
|
||||||
|
match effect_type {
|
||||||
|
"runExternalSync" => {
|
||||||
|
if generation == current_generation(&engine.state, "syncGeneration") {
|
||||||
|
engine.state["sync"]["isLoading"] = json!(false);
|
||||||
|
if result.status == "ok" {
|
||||||
|
engine.state["sync"]["snapshot"] = result.value.clone();
|
||||||
|
engine.state["sync"]["error"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["sync"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"syncExternalIntegration" => {
|
||||||
|
if generation == current_generation(&engine.state, "syncGeneration") {
|
||||||
|
engine.state["sync"]["isLoading"] = json!(false);
|
||||||
|
if result.status == "ok" {
|
||||||
|
let updated_profile =
|
||||||
|
result.value.get("profile").cloned().unwrap_or(Value::Null);
|
||||||
|
engine.state["sync"]["snapshot"] =
|
||||||
|
result.value.get("snapshot").cloned().unwrap_or(Value::Null);
|
||||||
|
if !updated_profile.is_null() {
|
||||||
|
engine.state["profile"]["active"] = updated_profile.clone();
|
||||||
|
engine.state["home"]["activeProfile"] = updated_profile;
|
||||||
|
}
|
||||||
|
engine.state["home"]["externalContinueWatching"] = result
|
||||||
|
.value
|
||||||
|
.get("externalContinueWatching")
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| json!([]));
|
||||||
|
engine.state["sync"]["error"] = Value::Null;
|
||||||
|
} else {
|
||||||
|
engine.state["sync"]["error"] = normalize_error(result.error.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
847
src/home_ranking.rs
Normal file
847
src/home_ranking.rs
Normal file
|
|
@ -0,0 +1,847 @@
|
||||||
|
use crate::content_identity::{imdb_id, normalized_billboard_title};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
const CORE_SHELF_KEYS: &[&str] = &[
|
||||||
|
"action",
|
||||||
|
"adventure",
|
||||||
|
"aksiyon",
|
||||||
|
"macera",
|
||||||
|
"sci fi",
|
||||||
|
"science fiction",
|
||||||
|
"bilim kurgu",
|
||||||
|
"fantasy",
|
||||||
|
"fantastik",
|
||||||
|
"thriller",
|
||||||
|
"gerilim",
|
||||||
|
"crime",
|
||||||
|
"suc",
|
||||||
|
"comedy",
|
||||||
|
"komedi",
|
||||||
|
"drama",
|
||||||
|
"dram",
|
||||||
|
"family",
|
||||||
|
"aile",
|
||||||
|
"kids",
|
||||||
|
"cocuk",
|
||||||
|
"anime",
|
||||||
|
"mini series",
|
||||||
|
"mini dizi",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct NativeHomeCategory {
|
||||||
|
name: String,
|
||||||
|
items: Vec<Value>,
|
||||||
|
id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
content_type: String,
|
||||||
|
semantic_name: Option<String>,
|
||||||
|
movie_genre: Option<String>,
|
||||||
|
series_genre: Option<String>,
|
||||||
|
skip: Option<i32>,
|
||||||
|
can_load_more: Option<bool>,
|
||||||
|
catalog_id: Option<String>,
|
||||||
|
addon_transport_url: Option<String>,
|
||||||
|
addon_genre: Option<String>,
|
||||||
|
catalog_sources: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct HomeOptimizeRequest {
|
||||||
|
categories: Vec<NativeHomeCategory>,
|
||||||
|
preferred_order_labels: Vec<String>,
|
||||||
|
preferred_genres: HashMap<String, i32>,
|
||||||
|
preferred_types: HashMap<String, i32>,
|
||||||
|
priority_labels: HomePriorityLabels,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct HomePriorityLabels {
|
||||||
|
trending_now: String,
|
||||||
|
popular_for_you: String,
|
||||||
|
most_watched: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct EditorialPickSpec {
|
||||||
|
title: String,
|
||||||
|
min_year: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meta_text<'a>(meta: &'a Value, key: &str) -> &'a str {
|
||||||
|
meta.get(key).and_then(Value::as_str).unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meta_i64(meta: &Value, key: &str) -> Option<i64> {
|
||||||
|
meta.get(key).and_then(Value::as_i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn meta_string_array(meta: &Value, key: &str) -> Vec<String> {
|
||||||
|
meta.get(key)
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn category_semantic_name(category: &NativeHomeCategory) -> &str {
|
||||||
|
category
|
||||||
|
.semantic_name
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(&category.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalize_home_key(value: &str) -> String {
|
||||||
|
let mut output = String::with_capacity(value.len());
|
||||||
|
let mut last_space = false;
|
||||||
|
for ch in value.to_lowercase().chars() {
|
||||||
|
let normalized = match ch {
|
||||||
|
'ç' => 'c',
|
||||||
|
'ğ' => 'g',
|
||||||
|
'ı' => 'i',
|
||||||
|
'ö' => 'o',
|
||||||
|
'ş' => 's',
|
||||||
|
'ü' => 'u',
|
||||||
|
ch if ch.is_ascii_alphanumeric() => ch,
|
||||||
|
_ => ' ',
|
||||||
|
};
|
||||||
|
if normalized == ' ' {
|
||||||
|
if !last_space {
|
||||||
|
output.push(' ');
|
||||||
|
last_space = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output.push(normalized);
|
||||||
|
last_space = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn semantic_score(category: &NativeHomeCategory, item: &Value) -> i32 {
|
||||||
|
let category_keys = [
|
||||||
|
Some(category.name.as_str()),
|
||||||
|
Some(category_semantic_name(category)),
|
||||||
|
category.addon_genre.as_deref(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.map(normalize_home_key)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let genre_score = meta_string_array(item, "genres")
|
||||||
|
.into_iter()
|
||||||
|
.map(|genre| normalize_home_key(&genre))
|
||||||
|
.filter(|genre| {
|
||||||
|
category_keys
|
||||||
|
.iter()
|
||||||
|
.any(|key| key == genre || key.contains(genre) || genre.contains(key))
|
||||||
|
})
|
||||||
|
.count() as i32
|
||||||
|
* 4;
|
||||||
|
let title_score = [meta_text(item, "name"), meta_text(item, "originalName")]
|
||||||
|
.into_iter()
|
||||||
|
.map(normalize_home_key)
|
||||||
|
.filter(|title| {
|
||||||
|
category_keys
|
||||||
|
.iter()
|
||||||
|
.any(|key| !key.is_empty() && title.contains(key))
|
||||||
|
})
|
||||||
|
.count() as i32
|
||||||
|
* 2;
|
||||||
|
genre_score + title_score
|
||||||
|
}
|
||||||
|
|
||||||
|
fn curated_items(category: &NativeHomeCategory) -> Vec<Value> {
|
||||||
|
let mut values = category
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| (item.clone(), semantic_score(category, item)))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
values.sort_by(|(left, left_score), (right, right_score)| {
|
||||||
|
right_score
|
||||||
|
.cmp(left_score)
|
||||||
|
.then_with(|| {
|
||||||
|
meta_i64(left, "rank")
|
||||||
|
.unwrap_or(i64::MAX)
|
||||||
|
.cmp(&meta_i64(right, "rank").unwrap_or(i64::MAX))
|
||||||
|
})
|
||||||
|
.then_with(|| {
|
||||||
|
meta_text(right, "imdbRating")
|
||||||
|
.parse::<f32>()
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
.partial_cmp(&meta_text(left, "imdbRating").parse::<f32>().unwrap_or(0.0))
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
values
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(item, _)| {
|
||||||
|
let id = meta_text(&item, "id").to_string();
|
||||||
|
if seen.insert(id) {
|
||||||
|
Some(item)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.take(24)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn curate_home_items_json(category_json: &str) -> Option<String> {
|
||||||
|
let category = serde_json::from_str::<NativeHomeCategory>(category_json).ok()?;
|
||||||
|
serde_json::to_string(&curated_items(&category)).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_pinned(category: &NativeHomeCategory) -> bool {
|
||||||
|
category.id == "library"
|
||||||
|
|| category.id == "watchlist"
|
||||||
|
|| category.id == "continue_watching"
|
||||||
|
|| category.content_type == "collection"
|
||||||
|
|| category.content_type == "collection_folder"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn priority_boost(category: &NativeHomeCategory, labels: &HomePriorityLabels) -> i32 {
|
||||||
|
let key = normalize_home_key(category_semantic_name(category));
|
||||||
|
if key.contains(&normalize_home_key(&labels.trending_now)) {
|
||||||
|
40
|
||||||
|
} else if key.contains(&normalize_home_key(&labels.popular_for_you)) {
|
||||||
|
32
|
||||||
|
} else if key.contains(&normalize_home_key(&labels.most_watched)) {
|
||||||
|
28
|
||||||
|
} else if key.contains("new") || key.contains("yeni") {
|
||||||
|
16
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn personalization_score(
|
||||||
|
category: &NativeHomeCategory,
|
||||||
|
preferred_genres: &HashMap<String, i32>,
|
||||||
|
preferred_types: &HashMap<String, i32>,
|
||||||
|
labels: &HomePriorityLabels,
|
||||||
|
) -> i32 {
|
||||||
|
let type_affinity = category
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| {
|
||||||
|
preferred_types
|
||||||
|
.get(meta_text(item, "type"))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(0)
|
||||||
|
})
|
||||||
|
.sum::<i32>()
|
||||||
|
* 12;
|
||||||
|
let genre_affinity = category
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.flat_map(|item| meta_string_array(item, "genres"))
|
||||||
|
.map(|genre| {
|
||||||
|
preferred_genres
|
||||||
|
.get(&normalize_home_key(&genre))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(0)
|
||||||
|
})
|
||||||
|
.sum::<i32>()
|
||||||
|
* 10;
|
||||||
|
let unique_top_items = category
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.take(10)
|
||||||
|
.map(|item| meta_text(item, "id").to_string())
|
||||||
|
.collect::<HashSet<_>>()
|
||||||
|
.len() as i32
|
||||||
|
* 8;
|
||||||
|
let reason_boost = category
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| !meta_text(item, "reason").is_empty())
|
||||||
|
.count() as i32
|
||||||
|
* 14;
|
||||||
|
type_affinity
|
||||||
|
+ genre_affinity
|
||||||
|
+ unique_top_items
|
||||||
|
+ reason_boost
|
||||||
|
+ priority_boost(category, labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlap_ratio(first: &NativeHomeCategory, second: &NativeHomeCategory) -> f32 {
|
||||||
|
let first_ids = first
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.take(12)
|
||||||
|
.map(|item| meta_text(item, "id").to_string())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
let second_ids = second
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.take(12)
|
||||||
|
.map(|item| meta_text(item, "id").to_string())
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
if first_ids.is_empty() || second_ids.is_empty() {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
first_ids.intersection(&second_ids).count() as f32
|
||||||
|
/ first_ids.len().min(second_ids.len()) as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn home_overlap_ratio_json(first_json: &str, second_json: &str) -> Option<f32> {
|
||||||
|
let first = serde_json::from_str::<NativeHomeCategory>(first_json).ok()?;
|
||||||
|
let second = serde_json::from_str::<NativeHomeCategory>(second_json).ok()?;
|
||||||
|
Some(overlap_ratio(&first, &second))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_core_genre_shelf(category: &NativeHomeCategory) -> bool {
|
||||||
|
if category.movie_genre.is_some()
|
||||||
|
|| category.series_genre.is_some()
|
||||||
|
|| category.addon_genre.is_some()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let key = normalize_home_key(category_semantic_name(category));
|
||||||
|
CORE_SHELF_KEYS
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| key == *candidate || key.contains(candidate))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cluster_key(category: &NativeHomeCategory) -> Option<String> {
|
||||||
|
if let Some(genre) = category.movie_genre.as_deref() {
|
||||||
|
return Some(format!("movie:{}", normalize_home_key(genre)));
|
||||||
|
}
|
||||||
|
if let Some(genre) = category.series_genre.as_deref() {
|
||||||
|
return Some(format!("series:{}", normalize_home_key(genre)));
|
||||||
|
}
|
||||||
|
if let Some(genre) = category.addon_genre.as_deref() {
|
||||||
|
return Some(format!("addon:{}", normalize_home_key(genre)));
|
||||||
|
}
|
||||||
|
let key = normalize_home_key(category_semantic_name(category));
|
||||||
|
CORE_SHELF_KEYS
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| key == **candidate || key.contains(*candidate))
|
||||||
|
.map(|value| (*value).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cluster_overlap_ratio(first: &NativeHomeCategory, second: &NativeHomeCategory) -> f32 {
|
||||||
|
let Some(first_cluster) = cluster_key(first) else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
|
let Some(second_cluster) = cluster_key(second) else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
|
if first_cluster == second_cluster {
|
||||||
|
overlap_ratio(first, second)
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn home_personalization_score_json(
|
||||||
|
category_json: &str,
|
||||||
|
preferred_genres_json: &str,
|
||||||
|
preferred_types_json: &str,
|
||||||
|
priority_labels_json: &str,
|
||||||
|
) -> Option<i32> {
|
||||||
|
let category = serde_json::from_str::<NativeHomeCategory>(category_json).ok()?;
|
||||||
|
let preferred_genres =
|
||||||
|
serde_json::from_str::<HashMap<String, i32>>(preferred_genres_json).ok()?;
|
||||||
|
let preferred_types =
|
||||||
|
serde_json::from_str::<HashMap<String, i32>>(preferred_types_json).ok()?;
|
||||||
|
let labels = serde_json::from_str::<HomePriorityLabels>(priority_labels_json).ok()?;
|
||||||
|
Some(personalization_score(
|
||||||
|
&category,
|
||||||
|
&preferred_genres,
|
||||||
|
&preferred_types,
|
||||||
|
&labels,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn home_prioritize_rows_json(
|
||||||
|
categories_json: &str,
|
||||||
|
preferred_order_labels_json: &str,
|
||||||
|
preferred_genres_json: &str,
|
||||||
|
preferred_types_json: &str,
|
||||||
|
priority_labels_json: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let mut categories = serde_json::from_str::<Vec<NativeHomeCategory>>(categories_json).ok()?;
|
||||||
|
let preferred_order_labels =
|
||||||
|
serde_json::from_str::<Vec<String>>(preferred_order_labels_json).ok()?;
|
||||||
|
let preferred_genres =
|
||||||
|
serde_json::from_str::<HashMap<String, i32>>(preferred_genres_json).ok()?;
|
||||||
|
let preferred_types =
|
||||||
|
serde_json::from_str::<HashMap<String, i32>>(preferred_types_json).ok()?;
|
||||||
|
let labels = serde_json::from_str::<HomePriorityLabels>(priority_labels_json).ok()?;
|
||||||
|
let preferred_order = preferred_order_labels
|
||||||
|
.iter()
|
||||||
|
.map(|value| normalize_home_key(value))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
categories.sort_by(|left, right| {
|
||||||
|
let left_index = preferred_order
|
||||||
|
.iter()
|
||||||
|
.position(|key| key == &normalize_home_key(category_semantic_name(left)))
|
||||||
|
.unwrap_or(usize::MAX);
|
||||||
|
let right_index = preferred_order
|
||||||
|
.iter()
|
||||||
|
.position(|key| key == &normalize_home_key(category_semantic_name(right)))
|
||||||
|
.unwrap_or(usize::MAX);
|
||||||
|
left_index.cmp(&right_index).then_with(|| {
|
||||||
|
personalization_score(right, &preferred_genres, &preferred_types, &labels).cmp(
|
||||||
|
&personalization_score(left, &preferred_genres, &preferred_types, &labels),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
serde_json::to_string(&categories).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn optimize_home_rows_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<HomeOptimizeRequest>(request_json).ok()?;
|
||||||
|
if request.categories.is_empty() {
|
||||||
|
return Some("[]".to_string());
|
||||||
|
}
|
||||||
|
let pinned = distinct_categories(
|
||||||
|
request
|
||||||
|
.categories
|
||||||
|
.iter()
|
||||||
|
.filter(|category| is_pinned(category))
|
||||||
|
.cloned(),
|
||||||
|
);
|
||||||
|
let mut candidates = distinct_categories(
|
||||||
|
request
|
||||||
|
.categories
|
||||||
|
.into_iter()
|
||||||
|
.filter(|category| !is_pinned(category)),
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.map(|mut category| {
|
||||||
|
category.items = curated_items(&category);
|
||||||
|
category
|
||||||
|
})
|
||||||
|
.filter(|category| category.items.len() >= 4)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let preferred_order = request
|
||||||
|
.preferred_order_labels
|
||||||
|
.iter()
|
||||||
|
.map(|value| normalize_home_key(value))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
candidates.sort_by(|left, right| {
|
||||||
|
let left_index = preferred_order
|
||||||
|
.iter()
|
||||||
|
.position(|key| key == &normalize_home_key(category_semantic_name(left)))
|
||||||
|
.unwrap_or(usize::MAX);
|
||||||
|
let right_index = preferred_order
|
||||||
|
.iter()
|
||||||
|
.position(|key| key == &normalize_home_key(category_semantic_name(right)))
|
||||||
|
.unwrap_or(usize::MAX);
|
||||||
|
left_index.cmp(&right_index).then_with(|| {
|
||||||
|
personalization_score(
|
||||||
|
right,
|
||||||
|
&request.preferred_genres,
|
||||||
|
&request.preferred_types,
|
||||||
|
&request.priority_labels,
|
||||||
|
)
|
||||||
|
.cmp(&personalization_score(
|
||||||
|
left,
|
||||||
|
&request.preferred_genres,
|
||||||
|
&request.preferred_types,
|
||||||
|
&request.priority_labels,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut kept = Vec::<NativeHomeCategory>::new();
|
||||||
|
for category in candidates.iter() {
|
||||||
|
let overlap = kept
|
||||||
|
.iter()
|
||||||
|
.map(|existing| overlap_ratio(existing, category))
|
||||||
|
.fold(0.0, f32::max);
|
||||||
|
let cluster_overlap = kept
|
||||||
|
.iter()
|
||||||
|
.map(|existing| cluster_overlap_ratio(existing, category))
|
||||||
|
.fold(0.0, f32::max);
|
||||||
|
let min_unique = category
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.take(12)
|
||||||
|
.map(|item| meta_text(item, "id").to_string())
|
||||||
|
.collect::<HashSet<_>>()
|
||||||
|
.len();
|
||||||
|
if min_unique < 5 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if is_core_genre_shelf(category)
|
||||||
|
|| (overlap < 0.68 && cluster_overlap < 0.52)
|
||||||
|
|| kept.len() < 8
|
||||||
|
{
|
||||||
|
kept.push(category.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallback = candidates
|
||||||
|
.into_iter()
|
||||||
|
.filter(|candidate| {
|
||||||
|
kept.iter().all(|existing| existing.id != candidate.id)
|
||||||
|
&& kept.iter().all(|existing| {
|
||||||
|
overlap_ratio(existing, candidate) < 0.68
|
||||||
|
&& cluster_overlap_ratio(existing, candidate) < 0.52
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.take(24usize.saturating_sub(kept.len()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut output = pinned;
|
||||||
|
output.extend(kept);
|
||||||
|
output.extend(fallback);
|
||||||
|
let limit = 24 + output_pinned_count(&output);
|
||||||
|
let output = distinct_categories(output.into_iter())
|
||||||
|
.into_iter()
|
||||||
|
.take(limit)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
serde_json::to_string(&output).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_pinned_count(categories: &[NativeHomeCategory]) -> usize {
|
||||||
|
categories
|
||||||
|
.iter()
|
||||||
|
.filter(|category| is_pinned(category))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn distinct_categories<I>(categories: I) -> Vec<NativeHomeCategory>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = NativeHomeCategory>,
|
||||||
|
{
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
categories
|
||||||
|
.into_iter()
|
||||||
|
.filter(|category| seen.insert(category.id.clone()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_billboard_backdrop_candidate_json(meta_json: &str) -> bool {
|
||||||
|
serde_json::from_str::<Value>(meta_json)
|
||||||
|
.ok()
|
||||||
|
.is_some_and(|meta| has_backdrop_candidate(&meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_backdrop_candidate(meta: &Value) -> bool {
|
||||||
|
let background = meta_text(meta, "background");
|
||||||
|
!background.is_empty() && !background.eq_ignore_ascii_case(meta_text(meta, "poster"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn billboard_score_candidate_json(
|
||||||
|
meta_json: &str,
|
||||||
|
days_since_release: Option<i64>,
|
||||||
|
) -> Option<i32> {
|
||||||
|
let meta = serde_json::from_str::<Value>(meta_json).ok()?;
|
||||||
|
Some(score_candidate(&meta, days_since_release))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn score_candidate(meta: &Value, days_since_release: Option<i64>) -> i32 {
|
||||||
|
let release_boost = match days_since_release {
|
||||||
|
None => 0,
|
||||||
|
Some(days) if days < 0 => 40,
|
||||||
|
Some(days) if days <= 14 => 440,
|
||||||
|
Some(days) if days <= 45 => 280,
|
||||||
|
Some(days) if days <= 120 => 120,
|
||||||
|
Some(_) => 0,
|
||||||
|
};
|
||||||
|
let type_boost = if meta_text(meta, "type") == "series" {
|
||||||
|
320
|
||||||
|
} else {
|
||||||
|
140
|
||||||
|
};
|
||||||
|
let rank_boost = meta_i64(meta, "rank")
|
||||||
|
.map(|rank| (220 - ((rank as i32 - 1) * 18)).max(0))
|
||||||
|
.unwrap_or(0);
|
||||||
|
let rating_boost = (meta_text(meta, "imdbRating").parse::<f32>().unwrap_or(0.0) * 22.0) as i32;
|
||||||
|
let recommendation_boost = if meta_text(meta, "reason").is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
180
|
||||||
|
};
|
||||||
|
let editorial_boost = if meta_text(meta, "reason") == "EDITORIAL_SPOTLIGHT" {
|
||||||
|
520
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let backdrop_boost = if has_backdrop_candidate(meta) {
|
||||||
|
260
|
||||||
|
} else if !meta_text(meta, "poster").is_empty() {
|
||||||
|
40
|
||||||
|
} else {
|
||||||
|
-240
|
||||||
|
};
|
||||||
|
type_boost
|
||||||
|
+ release_boost
|
||||||
|
+ rank_boost
|
||||||
|
+ rating_boost
|
||||||
|
+ recommendation_boost
|
||||||
|
+ editorial_boost
|
||||||
|
+ backdrop_boost
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn billboard_visual_score_json(meta_json: &str) -> Option<i32> {
|
||||||
|
let meta = serde_json::from_str::<Value>(meta_json).ok()?;
|
||||||
|
let mut score = 0;
|
||||||
|
if has_backdrop_candidate(&meta) {
|
||||||
|
score += 320;
|
||||||
|
} else {
|
||||||
|
score -= 160;
|
||||||
|
}
|
||||||
|
if !meta_text(&meta, "logo").is_empty() {
|
||||||
|
score += 120;
|
||||||
|
}
|
||||||
|
if !meta_text(&meta, "description").is_empty() {
|
||||||
|
score += 30;
|
||||||
|
}
|
||||||
|
Some(score)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn billboard_editorial_match_score_json(
|
||||||
|
meta_json: &str,
|
||||||
|
spec_json: &str,
|
||||||
|
) -> Option<i32> {
|
||||||
|
let meta = serde_json::from_str::<Value>(meta_json).ok()?;
|
||||||
|
let spec = serde_json::from_str::<EditorialPickSpec>(spec_json).ok()?;
|
||||||
|
let _ = spec.title;
|
||||||
|
let release_year = meta_text(&meta, "releaseInfo").parse::<i32>().unwrap_or(0);
|
||||||
|
let year_boost = if release_year >= spec.min_year {
|
||||||
|
400
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let rating_boost = (meta_text(&meta, "imdbRating").parse::<f32>().unwrap_or(0.0) * 20.0) as i32;
|
||||||
|
let rank_boost = meta_i64(&meta, "rank")
|
||||||
|
.map(|rank| (180 - (rank as i32 * 12)).max(0))
|
||||||
|
.unwrap_or(0);
|
||||||
|
Some(year_boost + rating_boost + rank_boost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Billboard pool selection ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn billboard_key_value(meta: &Value) -> String {
|
||||||
|
let id = meta_text(meta, "id");
|
||||||
|
if let Some(iid) = imdb_id(id) {
|
||||||
|
return format!("{}:{iid}", meta_text(meta, "type"));
|
||||||
|
}
|
||||||
|
let name = meta
|
||||||
|
.get("originalName")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.unwrap_or_else(|| meta_text(meta, "name"));
|
||||||
|
let year = meta_text(meta, "releaseInfo")
|
||||||
|
.get(0..4)
|
||||||
|
.or_else(|| meta_text(meta, "released").get(0..4))
|
||||||
|
.unwrap_or("");
|
||||||
|
format!(
|
||||||
|
"{}:{}:{year}",
|
||||||
|
meta_text(meta, "type"),
|
||||||
|
normalized_billboard_title(name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title_key_value(meta: &Value) -> String {
|
||||||
|
let name = meta
|
||||||
|
.get("originalName")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.unwrap_or_else(|| meta_text(meta, "name"));
|
||||||
|
normalized_billboard_title(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn distinct_by_billboard_key(items: Vec<Value>) -> Vec<Value> {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.filter(|m| seen.insert(billboard_key_value(m)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn distinct_by_title_key(items: Vec<Value>) -> Vec<Value> {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
items
|
||||||
|
.into_iter()
|
||||||
|
.filter(|m| seen.insert(title_key_value(m)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn billboard_visual_score(meta: &Value) -> i32 {
|
||||||
|
let mut score = 0i32;
|
||||||
|
if has_backdrop_candidate(meta) {
|
||||||
|
score += 320;
|
||||||
|
} else {
|
||||||
|
score -= 160;
|
||||||
|
}
|
||||||
|
if !meta_text(meta, "logo").is_empty() {
|
||||||
|
score += 120;
|
||||||
|
}
|
||||||
|
if !meta_text(meta, "description").is_empty() {
|
||||||
|
score += 30;
|
||||||
|
}
|
||||||
|
score
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects a billboard pool of up to 10 items from the enriched+raw candidate sets.
|
||||||
|
///
|
||||||
|
/// `enriched_json` — candidates that have been enriched via IO (backdrop, logo, etc.).
|
||||||
|
/// `candidates_json` — the full original candidate list (before enrichment).
|
||||||
|
///
|
||||||
|
/// Decision logic (what runs here, not on the Kotlin side):
|
||||||
|
/// • editorial picks — up to 3 items with reason == "EDITORIAL_SPOTLIGHT"
|
||||||
|
/// • series picks — up to 8 items of type "series"
|
||||||
|
/// • movie picks — up to 3 items of type "movie"
|
||||||
|
/// • combined, deduplicated, filled to 10 from remaining ranked items
|
||||||
|
pub(crate) fn build_billboard_pool_json(
|
||||||
|
enriched_json: &str,
|
||||||
|
candidates_json: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let enriched: Vec<Value> = serde_json::from_str(enriched_json).ok()?;
|
||||||
|
let candidates: Vec<Value> = serde_json::from_str(candidates_json).ok()?;
|
||||||
|
|
||||||
|
let enriched_by_key: HashMap<String, Value> = enriched
|
||||||
|
.iter()
|
||||||
|
.map(|m| (billboard_key_value(m), m.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Editorial picks: prefer the enriched version, fall back to original when it has artwork.
|
||||||
|
let editorial_raw: Vec<Value> = candidates
|
||||||
|
.iter()
|
||||||
|
.filter(|m| meta_text(m, "reason") == "EDITORIAL_SPOTLIGHT")
|
||||||
|
.filter_map(|m| {
|
||||||
|
let key = billboard_key_value(m);
|
||||||
|
enriched_by_key.get(&key).cloned().or_else(|| {
|
||||||
|
if has_backdrop_candidate(m) || !meta_text(m, "poster").is_empty() {
|
||||||
|
Some(m.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut editorial = editorial_raw;
|
||||||
|
editorial.sort_by(|a, b| score_candidate(b, None).cmp(&score_candidate(a, None)));
|
||||||
|
let editorial: Vec<Value> = distinct_by_title_key(editorial)
|
||||||
|
.into_iter()
|
||||||
|
.take(3)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Ranked pool: merge enriched + candidates, deduplicate, filter, sort by score+visual.
|
||||||
|
let combined: Vec<Value> = enriched.into_iter().chain(candidates).collect();
|
||||||
|
let combined = distinct_by_title_key(distinct_by_billboard_key(combined));
|
||||||
|
let mut ranked: Vec<Value> = combined
|
||||||
|
.into_iter()
|
||||||
|
.filter(|m| has_backdrop_candidate(m) || !meta_text(m, "poster").is_empty())
|
||||||
|
.collect();
|
||||||
|
ranked.sort_by(|a, b| {
|
||||||
|
let sb = score_candidate(b, None) + billboard_visual_score(b);
|
||||||
|
let sa = score_candidate(a, None) + billboard_visual_score(a);
|
||||||
|
sb.cmp(&sa)
|
||||||
|
});
|
||||||
|
|
||||||
|
let series: Vec<Value> = ranked
|
||||||
|
.iter()
|
||||||
|
.filter(|m| meta_text(m, "type") == "series")
|
||||||
|
.take(8)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let movies: Vec<Value> = ranked
|
||||||
|
.iter()
|
||||||
|
.filter(|m| meta_text(m, "type") == "movie")
|
||||||
|
.take(3)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let preferred: Vec<Value> = distinct_by_title_key(distinct_by_billboard_key(
|
||||||
|
editorial.into_iter().chain(series).chain(movies).collect(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let final_pool: Vec<Value> = if preferred.len() >= 10 {
|
||||||
|
preferred.into_iter().take(10).collect()
|
||||||
|
} else {
|
||||||
|
let preferred_keys: HashSet<String> =
|
||||||
|
preferred.iter().map(billboard_key_value).collect();
|
||||||
|
let preferred_titles: HashSet<String> =
|
||||||
|
preferred.iter().map(title_key_value).collect();
|
||||||
|
let extras = ranked.into_iter().filter(|m| {
|
||||||
|
!preferred_keys.contains(&billboard_key_value(m))
|
||||||
|
&& !preferred_titles.contains(&title_key_value(m))
|
||||||
|
});
|
||||||
|
preferred.into_iter().chain(extras).take(10).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
serde_json::to_string(&final_pool).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Catalog item normalisation ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn iso_date_part(date_str: &str) -> Option<&str> {
|
||||||
|
let s = date_str.trim();
|
||||||
|
if s.len() < 10 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let date_part = &s[..10];
|
||||||
|
let b = date_part.as_bytes();
|
||||||
|
if b[4] == b'-' && b[7] == b'-' {
|
||||||
|
Some(date_part)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_upcoming_date(date_str: &str, today_iso: &str) -> bool {
|
||||||
|
iso_date_part(date_str).is_some_and(|d| d > today_iso)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANKED_CATALOG_IDS: &[&str] = &["trending", "popular", "top", "now_playing"];
|
||||||
|
|
||||||
|
/// Normalises catalog items on behalf of the Kotlin HomeCatalogItemNormalizer.
|
||||||
|
///
|
||||||
|
/// Decisions kept here (Rust decides, Kotlin executes):
|
||||||
|
/// • Assign `rank = index + 1` for ranking catalogs when no genre filter is active.
|
||||||
|
/// • Drop items whose release date is still in the future (upcoming releases).
|
||||||
|
///
|
||||||
|
/// `genre` being `None` / empty string means no genre filter is active.
|
||||||
|
/// `today_iso` must be supplied as "YYYY-MM-DD" by the caller.
|
||||||
|
pub(crate) fn normalize_home_catalog_items_json(
|
||||||
|
items_json: &str,
|
||||||
|
catalog_id: &str,
|
||||||
|
genre: Option<&str>,
|
||||||
|
today_iso: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
|
||||||
|
let assign_rank = genre.map(|g| g.is_empty()).unwrap_or(true)
|
||||||
|
&& RANKED_CATALOG_IDS.contains(&catalog_id);
|
||||||
|
|
||||||
|
let mut rank: i64 = 0;
|
||||||
|
let result: Vec<Value> = items
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|mut item| {
|
||||||
|
let released = item.get("released").and_then(Value::as_str).unwrap_or("");
|
||||||
|
if is_upcoming_date(released, today_iso) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if assign_rank {
|
||||||
|
rank += 1;
|
||||||
|
if let Some(obj) = item.as_object_mut() {
|
||||||
|
obj.insert("rank".to_string(), json!(rank));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(item)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
serde_json::to_string(&result).ok()
|
||||||
|
}
|
||||||
173
src/intro_segments.rs
Normal file
173
src/intro_segments.rs
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub(crate) fn parse_intro_db_segments_json(data_json: &str) -> Option<String> {
|
||||||
|
let data: Value = serde_json::from_str(data_json).ok()?;
|
||||||
|
let segments = collect_segments(&data);
|
||||||
|
serde_json::to_string(&segments).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_segments(data: &Value) -> Vec<Value> {
|
||||||
|
match data {
|
||||||
|
Value::Array(arr) => arr.iter().flat_map(collect_segments).collect(),
|
||||||
|
Value::Object(obj) => {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for key in &["segments", "results", "data", "items"] {
|
||||||
|
if let Some(child) = obj.get(*key) {
|
||||||
|
result.extend(collect_segments(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for seg_type in &["intro", "outro", "recap"] {
|
||||||
|
if let Some(child) = obj.get(*seg_type) {
|
||||||
|
if let Some(seg) = segment_from_object_with_type(child, seg_type) {
|
||||||
|
result.push(seg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let start = number_from_keys(obj, &[
|
||||||
|
&format!("{seg_type}Start"), &format!("{seg_type}_start"),
|
||||||
|
&format!("{seg_type}StartTime"), &format!("{seg_type}_start_time"),
|
||||||
|
&format!("{seg_type}StartMs"), &format!("{seg_type}_start_ms"),
|
||||||
|
]);
|
||||||
|
let end = number_from_keys(obj, &[
|
||||||
|
&format!("{seg_type}End"), &format!("{seg_type}_end"),
|
||||||
|
&format!("{seg_type}EndTime"), &format!("{seg_type}_end_time"),
|
||||||
|
&format!("{seg_type}EndMs"), &format!("{seg_type}_end_ms"),
|
||||||
|
]);
|
||||||
|
if let (Some(s), Some(e)) = (start, end) {
|
||||||
|
let start_ms = normalize_time(s);
|
||||||
|
let end_ms = normalize_time(e);
|
||||||
|
if end_ms > start_ms {
|
||||||
|
result.push(make_segment(seg_type, start_ms, end_ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(seg) = segment_from_object(obj) {
|
||||||
|
if !result.iter().any(|r| r == &seg) {
|
||||||
|
result.push(seg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.into_iter().filter(|s| {
|
||||||
|
let st = s.get("startTime").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let et = s.get("endTime").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
et > st
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segment_from_object(obj: &serde_json::Map<String, Value>) -> Option<Value> {
|
||||||
|
let start = number_from_keys(obj, &["startTime", "start", "from", "start_sec", "start_time", "startTimeMs", "start_ms", "startOffset"])?;
|
||||||
|
let end = number_from_keys(obj, &["endTime", "end", "to", "end_sec", "end_time", "endTimeMs", "end_ms", "endOffset"])?;
|
||||||
|
let raw_type = string_from_keys(obj, &["segment_type", "skip_type", "category", "name", "type"]).unwrap_or_else(|| "intro".to_string());
|
||||||
|
let seg_type = normalize_skip_type(&raw_type);
|
||||||
|
let start_ms = normalize_time(start);
|
||||||
|
let end_ms = normalize_time(end);
|
||||||
|
if end_ms <= start_ms { return None; }
|
||||||
|
Some(make_segment(seg_type, start_ms, end_ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn segment_from_object_with_type(value: &Value, fallback_type: &str) -> Option<Value> {
|
||||||
|
let obj = value.as_object()?;
|
||||||
|
let start = number_from_keys(obj, &["startTime", "start", "from", "start_time"])?;
|
||||||
|
let end = number_from_keys(obj, &["endTime", "end", "to", "end_time"])?;
|
||||||
|
let raw_type = string_from_keys(obj, &["type", "segment_type"]).unwrap_or_else(|| fallback_type.to_string());
|
||||||
|
let seg_type = normalize_skip_type(&raw_type);
|
||||||
|
let start_ms = normalize_time(start);
|
||||||
|
let end_ms = normalize_time(end);
|
||||||
|
if end_ms <= start_ms { return None; }
|
||||||
|
Some(make_segment(seg_type, start_ms, end_ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_segment(seg_type: &str, start_ms: i64, end_ms: i64) -> Value {
|
||||||
|
json!({ "type": seg_type, "startTime": start_ms, "endTime": end_ms })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number_from_keys(obj: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<f64> {
|
||||||
|
for key in keys {
|
||||||
|
match obj.get(*key) {
|
||||||
|
Some(Value::Number(n)) => if let Some(f) = n.as_f64() { return Some(f); },
|
||||||
|
Some(Value::String(s)) => if let Ok(f) = s.trim().parse::<f64>() { return Some(f); },
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_from_keys(obj: &serde_json::Map<String, Value>, keys: &[&str]) -> Option<String> {
|
||||||
|
for key in keys {
|
||||||
|
if let Some(Value::String(s)) = obj.get(*key) {
|
||||||
|
let t = s.trim();
|
||||||
|
if !t.is_empty() { return Some(t.to_string()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_time(value: f64) -> 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 {
|
||||||
|
match raw.to_lowercase().as_str() {
|
||||||
|
"op" | "opening" | "intro" | "mixed-intro" => "intro",
|
||||||
|
"ed" | "ending" | "outro" | "credits" => "outro",
|
||||||
|
"recap" | "previously" => "recap",
|
||||||
|
_ => "intro",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalize_skip_time(seconds: f64) -> i64 {
|
||||||
|
normalize_time(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_aniskip_results_json(results_json: &str) -> Option<String> {
|
||||||
|
let results: Value = serde_json::from_str(results_json).ok()?;
|
||||||
|
let items = results.get("results").and_then(Value::as_array)?;
|
||||||
|
let segments: Vec<Value> = items.iter().filter_map(|item| {
|
||||||
|
let skip_type = item.get("skipType").and_then(Value::as_str)?;
|
||||||
|
let interval = item.get("interval")?;
|
||||||
|
let start = interval.get("startTime").and_then(Value::as_f64)?;
|
||||||
|
let end = interval.get("endTime").and_then(Value::as_f64)?;
|
||||||
|
let start_ms = normalize_time(start);
|
||||||
|
let end_ms = normalize_time(end);
|
||||||
|
if end_ms <= start_ms { return None; }
|
||||||
|
Some(make_segment(normalize_skip_type(skip_type), start_ms, end_ms))
|
||||||
|
}).collect();
|
||||||
|
serde_json::to_string(&segments).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn unique_intro_segments_json(segments_a_json: &str, segments_b_json: &str) -> Option<String> {
|
||||||
|
let a: Vec<Value> = serde_json::from_str(segments_a_json).unwrap_or_default();
|
||||||
|
let b: Vec<Value> = serde_json::from_str(segments_b_json).unwrap_or_default();
|
||||||
|
dedup_and_sort(a.into_iter().chain(b.into_iter()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn merge_intro_segments_json(sources_json: &str) -> Option<String> {
|
||||||
|
let sources: Vec<Value> = serde_json::from_str(sources_json).ok()?;
|
||||||
|
let all: Vec<Value> = sources.into_iter().flat_map(|s| {
|
||||||
|
s.as_array().cloned().unwrap_or_default()
|
||||||
|
}).collect();
|
||||||
|
dedup_and_sort(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dedup_and_sort(segments: Vec<Value>) -> Option<String> {
|
||||||
|
let mut seen: HashMap<String, bool> = HashMap::new();
|
||||||
|
let mut result: Vec<Value> = Vec::new();
|
||||||
|
for seg in segments {
|
||||||
|
let key = format!(
|
||||||
|
"{}:{}:{}",
|
||||||
|
seg.get("type").and_then(Value::as_str).unwrap_or(""),
|
||||||
|
seg.get("startTime").and_then(Value::as_i64).unwrap_or(0),
|
||||||
|
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);
|
||||||
|
if end <= start { continue; }
|
||||||
|
if seen.insert(key, true).is_none() {
|
||||||
|
result.push(seg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.sort_by_key(|s| s.get("startTime").and_then(Value::as_i64).unwrap_or(0));
|
||||||
|
serde_json::to_string(&result).ok()
|
||||||
|
}
|
||||||
325
src/lib.rs
Normal file
325
src/lib.rs
Normal file
|
|
@ -0,0 +1,325 @@
|
||||||
|
uniffi::setup_scaffolding!();
|
||||||
|
|
||||||
|
mod addon_protocol;
|
||||||
|
mod addon_resource;
|
||||||
|
mod addon_store;
|
||||||
|
mod app_state;
|
||||||
|
mod calendar_plan;
|
||||||
|
mod content_identity;
|
||||||
|
pub mod core_api;
|
||||||
|
pub mod core_contract;
|
||||||
|
mod data_policy;
|
||||||
|
mod discovery_plan;
|
||||||
|
mod dolby_vision_rpu;
|
||||||
|
mod external_sync;
|
||||||
|
mod headless_adapter_plan;
|
||||||
|
mod headless_engine;
|
||||||
|
mod home_ranking;
|
||||||
|
mod intro_segments;
|
||||||
|
mod library_state;
|
||||||
|
mod offline_download;
|
||||||
|
mod platform_plan;
|
||||||
|
mod player_flow;
|
||||||
|
mod player_policy;
|
||||||
|
mod player_scrobble;
|
||||||
|
mod profile_contract;
|
||||||
|
mod profile_prefs;
|
||||||
|
mod repository_flow;
|
||||||
|
mod search_plan;
|
||||||
|
mod stream_policy;
|
||||||
|
mod tmdb_plan;
|
||||||
|
mod watchlist_plan;
|
||||||
|
|
||||||
|
pub mod addon_transport;
|
||||||
|
pub mod env;
|
||||||
|
pub mod runtime;
|
||||||
|
pub mod types;
|
||||||
|
|
||||||
|
pub mod bindings;
|
||||||
|
|
||||||
|
pub use core_api::FluxaCore;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::addon_protocol::{
|
||||||
|
catalog_has_required_extra_except, catalog_requires_extra, catalog_supports_extra,
|
||||||
|
};
|
||||||
|
use crate::content_identity::stream_request_ids;
|
||||||
|
use crate::home_ranking::optimize_home_rows_json;
|
||||||
|
use crate::stream_policy::{
|
||||||
|
stream_playback_info_json, stream_request_headers_json, stream_request_referer,
|
||||||
|
torrent_runtime_info_json, torrent_status_info_json,
|
||||||
|
};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_request_ids_keep_requested_tmdb_episode_before_canonical_fallback() {
|
||||||
|
assert_eq!(
|
||||||
|
stream_request_ids(
|
||||||
|
"series",
|
||||||
|
"tmdb:12345:1:2",
|
||||||
|
Some("tmdb:12345"),
|
||||||
|
Some("tmdb:12345"),
|
||||||
|
Some("tt9999999"),
|
||||||
|
),
|
||||||
|
vec!["tmdb:12345:1:2", "tt9999999:1:2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_request_ids_keep_custom_episode_id_before_canonical_fallback() {
|
||||||
|
assert_eq!(
|
||||||
|
stream_request_ids(
|
||||||
|
"series",
|
||||||
|
"kitsu:777:1:2",
|
||||||
|
Some("tt9999999"),
|
||||||
|
Some("kitsu:777"),
|
||||||
|
Some("tt9999999"),
|
||||||
|
),
|
||||||
|
vec!["kitsu:777:1:2", "tt9999999:1:2"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_request_ids_prefer_canonical_for_tmdb_movies() {
|
||||||
|
assert_eq!(
|
||||||
|
stream_request_ids("movie", "tmdb:12345", None, None, Some("tt9999999")),
|
||||||
|
vec!["tt9999999", "tmdb:12345"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_playback_info_reads_stremio_stream_fields_without_rewriting_result() {
|
||||||
|
let info = stream_playback_info_json(
|
||||||
|
r#"{
|
||||||
|
"name":"Source",
|
||||||
|
"url":"https://cdn.example/Breaking%20Bad.mkv",
|
||||||
|
"behaviorHints":{
|
||||||
|
"videoHash":"abc123",
|
||||||
|
"videoSize":42,
|
||||||
|
"filename":"Custom File.mkv"
|
||||||
|
}
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.expect("stream playback info");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
info.get("playableUrl").and_then(Value::as_str),
|
||||||
|
Some("https://cdn.example/Breaking%20Bad.mkv")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
info.get("effectiveVideoHash").and_then(Value::as_str),
|
||||||
|
Some("abc123")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
info.get("effectiveVideoSize").and_then(Value::as_i64),
|
||||||
|
Some(42)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
info.get("effectiveFilename").and_then(Value::as_str),
|
||||||
|
Some("Custom File.mkv")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
info.get("subtitleExtraArgs").and_then(Value::as_str),
|
||||||
|
Some("videoHash=abc123&videoSize=42&filename=Custom+File.mkv")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
info.get("isLikelyPlayerCompatible")
|
||||||
|
.and_then(Value::as_bool),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_playback_info_builds_torrent_url_from_info_hash() {
|
||||||
|
let info = stream_playback_info_json(r#"{"infoHash":"abcdef","fileIdx":3}"#)
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.expect("stream playback info");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
info.get("playableUrl").and_then(Value::as_str),
|
||||||
|
Some("stremio://torrent/abcdef/3")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
info.get("isTorrentPlaybackUrl").and_then(Value::as_bool),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
info.get("isLikelyPlayerCompatible")
|
||||||
|
.and_then(Value::as_bool),
|
||||||
|
Some(true)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn torrent_runtime_info_normalizes_link_and_resolves_file_index() {
|
||||||
|
// fileIdx provided by addon → use it directly, no episode matching
|
||||||
|
let info = torrent_runtime_info_json(
|
||||||
|
r#"{
|
||||||
|
"link":"stremio://torrent/ABCDEF1234567890ABCDEF1234567890ABCDEF12/4",
|
||||||
|
"title":"tt123:1:2",
|
||||||
|
"requestedFileIdx":1,
|
||||||
|
"preferredFilename":null,
|
||||||
|
"sources":["tracker:udp://tracker.example:1337/announce","tracker:udp://tracker.example:1337/announce"],
|
||||||
|
"fileStats":[
|
||||||
|
{"id":1,"path":"Show.S01E02.mkv","length":100},
|
||||||
|
{"id":2,"path":"Show.S01E03.mkv","length":300}
|
||||||
|
],
|
||||||
|
"rejectedIndex":null,
|
||||||
|
"baseUrl":"http://127.0.0.1:8090",
|
||||||
|
"play":true,
|
||||||
|
"stat":false
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.expect("torrent runtime info");
|
||||||
|
|
||||||
|
let normalized = info
|
||||||
|
.get("normalizedLink")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.expect("normalizedLink");
|
||||||
|
assert!(
|
||||||
|
normalized.starts_with("magnet:?xt=urn:btih:abcdef1234567890abcdef1234567890abcdef12"),
|
||||||
|
"unexpected magnet prefix: {normalized}"
|
||||||
|
);
|
||||||
|
// Addon-provided tracker survives dedupe (appears once).
|
||||||
|
assert_eq!(
|
||||||
|
normalized.matches("tracker.example%3A1337").count(),
|
||||||
|
1,
|
||||||
|
"addon tracker should appear exactly once: {normalized}"
|
||||||
|
);
|
||||||
|
// Fallback trackers are always appended for peer discovery.
|
||||||
|
assert!(
|
||||||
|
normalized.contains("opentrackr.org"),
|
||||||
|
"fallback tracker missing: {normalized}"
|
||||||
|
);
|
||||||
|
assert_eq!(info.get("selectedFileIdx").and_then(Value::as_i64), Some(1));
|
||||||
|
assert_eq!(
|
||||||
|
info.get("selectedReason").and_then(Value::as_str),
|
||||||
|
Some("requested")
|
||||||
|
);
|
||||||
|
|
||||||
|
// No fileIdx → falls back to largest video file
|
||||||
|
let fallback = torrent_runtime_info_json(
|
||||||
|
r#"{
|
||||||
|
"link":"stremio://torrent/ABCDEF1234567890ABCDEF1234567890ABCDEF12",
|
||||||
|
"title":"tt123:1:2",
|
||||||
|
"requestedFileIdx":null,
|
||||||
|
"preferredFilename":null,
|
||||||
|
"sources":[],
|
||||||
|
"fileStats":[
|
||||||
|
{"id":1,"path":"Show.S01E02.mkv","length":100},
|
||||||
|
{"id":2,"path":"Show.S01E03.mkv","length":300}
|
||||||
|
],
|
||||||
|
"rejectedIndex":null,
|
||||||
|
"baseUrl":"http://127.0.0.1:8090",
|
||||||
|
"play":true,
|
||||||
|
"stat":false
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.expect("torrent fallback info");
|
||||||
|
|
||||||
|
assert_eq!(fallback.get("selectedFileIdx").and_then(Value::as_i64), Some(2));
|
||||||
|
assert_eq!(
|
||||||
|
fallback.get("selectedReason").and_then(Value::as_str),
|
||||||
|
Some("largest-video")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn torrent_status_info_reports_progress_and_playability() {
|
||||||
|
let info = torrent_status_info_json(
|
||||||
|
r#"{
|
||||||
|
"stat":1,
|
||||||
|
"progress":4.0,
|
||||||
|
"loaded_size":262144,
|
||||||
|
"preload_size":524288
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.expect("torrent status info");
|
||||||
|
|
||||||
|
assert_eq!(info.get("bufferProgress").and_then(Value::as_i64), Some(50));
|
||||||
|
assert_eq!(
|
||||||
|
info.get("isPlayableEnough").and_then(Value::as_bool),
|
||||||
|
Some(false)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
info.get("statusKey").and_then(Value::as_str),
|
||||||
|
Some("player.torrent_status.preloading")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_request_headers_keep_only_explicit_clean_headers() {
|
||||||
|
let headers = stream_request_headers_json(r#"{"X-Test":"ok","":"ignored","Blank":""}"#)
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.expect("headers");
|
||||||
|
|
||||||
|
assert_eq!(headers.get("X-Test").and_then(Value::as_str), Some("ok"));
|
||||||
|
assert!(headers.get("").is_none());
|
||||||
|
assert!(headers.get("Blank").is_none());
|
||||||
|
assert_eq!(stream_request_referer("https://vidmoly.me/video.mp4"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catalog_extra_helpers_match_manifest_extra_shapes() {
|
||||||
|
let modern =
|
||||||
|
r#"{"type":"movie","id":"modern","extra":[{"name":"search","isRequired":true}]}"#;
|
||||||
|
let legacy = r#"{"type":"movie","id":"legacy","extraSupported":["genre"]}"#;
|
||||||
|
|
||||||
|
assert!(catalog_supports_extra(modern, "search"));
|
||||||
|
assert!(catalog_requires_extra(modern, "search"));
|
||||||
|
assert!(catalog_supports_extra(legacy, "genre"));
|
||||||
|
assert!(!catalog_requires_extra(legacy, "genre"));
|
||||||
|
assert!(!catalog_has_required_extra_except(modern, r#"["search"]"#));
|
||||||
|
assert!(catalog_has_required_extra_except(modern, "[]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn home_rows_keep_pinned_before_native_ranked_rows() {
|
||||||
|
let request = json!({
|
||||||
|
"categories": [
|
||||||
|
{"id":"popular","name":"Popular","semanticName":"Popular","type":"movie","items":[
|
||||||
|
{"id":"p1","name":"P1","type":"movie","poster":null},
|
||||||
|
{"id":"p2","name":"P2","type":"movie","poster":null},
|
||||||
|
{"id":"p3","name":"P3","type":"movie","poster":null},
|
||||||
|
{"id":"p4","name":"P4","type":"movie","poster":null},
|
||||||
|
{"id":"p5","name":"P5","type":"movie","poster":null},
|
||||||
|
{"id":"p6","name":"P6","type":"movie","poster":null}
|
||||||
|
]},
|
||||||
|
{"id":"continue_watching","name":"Continue Watching","semanticName":"Continue Watching","type":"movie","items":[
|
||||||
|
{"id":"cw1","name":"CW1","type":"movie","poster":null}
|
||||||
|
]},
|
||||||
|
{"id":"trending","name":"Trending Now","semanticName":"Trending Now","type":"movie","items":[
|
||||||
|
{"id":"t1","name":"T1","type":"movie","poster":null},
|
||||||
|
{"id":"t2","name":"T2","type":"movie","poster":null},
|
||||||
|
{"id":"t3","name":"T3","type":"movie","poster":null},
|
||||||
|
{"id":"t4","name":"T4","type":"movie","poster":null},
|
||||||
|
{"id":"t5","name":"T5","type":"movie","poster":null},
|
||||||
|
{"id":"t6","name":"T6","type":"movie","poster":null}
|
||||||
|
]}
|
||||||
|
],
|
||||||
|
"preferredOrderLabels": ["Trending Now", "Popular"],
|
||||||
|
"preferredGenres": {},
|
||||||
|
"preferredTypes": {},
|
||||||
|
"priorityLabels": {
|
||||||
|
"trendingNow": "Trending Now",
|
||||||
|
"popularForYou": "Popular For You",
|
||||||
|
"mostWatched": "Most Watched"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let rows = optimize_home_rows_json(&request.to_string())
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.expect("rows");
|
||||||
|
let rows = rows.as_array().expect("row array");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
rows[0].get("id").and_then(Value::as_str),
|
||||||
|
Some("continue_watching")
|
||||||
|
);
|
||||||
|
assert_eq!(rows[1].get("id").and_then(Value::as_str), Some("trending"));
|
||||||
|
}
|
||||||
|
}
|
||||||
550
src/library_state.rs
Normal file
550
src/library_state.rs
Normal file
|
|
@ -0,0 +1,550 @@
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
fn text<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
|
||||||
|
value.get(key).and_then(Value::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number(value: &Value, key: &str) -> Option<i64> {
|
||||||
|
value.get(key).and_then(Value::as_i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn library_item_from_meta(meta: &Value, state: Value, last_watched: Option<&str>) -> Value {
|
||||||
|
let mut item = json!({
|
||||||
|
"_id": text(meta, "id").unwrap_or(""),
|
||||||
|
"name": text(meta, "name").unwrap_or(""),
|
||||||
|
"type": text(meta, "type").unwrap_or(""),
|
||||||
|
"poster": meta.get("poster").cloned().unwrap_or(Value::Null),
|
||||||
|
"background": meta.get("background").cloned().unwrap_or(Value::Null),
|
||||||
|
"logo": meta.get("logo").cloned().unwrap_or(Value::Null),
|
||||||
|
"state": state
|
||||||
|
});
|
||||||
|
if let Some(last_watched) = last_watched {
|
||||||
|
item["lastWatched"] = Value::String(last_watched.to_string());
|
||||||
|
}
|
||||||
|
item
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn playback_progress_item_json(
|
||||||
|
meta_json: &str,
|
||||||
|
time_offset: i64,
|
||||||
|
duration: i64,
|
||||||
|
now_utc: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let meta: Value = serde_json::from_str(meta_json).ok()?;
|
||||||
|
let item = library_item_from_meta(
|
||||||
|
&meta,
|
||||||
|
json!({
|
||||||
|
"lastWatched": now_utc,
|
||||||
|
"timeOffset": time_offset,
|
||||||
|
"duration": duration
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
serde_json::to_string(&item).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clear_playback_progress_item_json(meta_json: &str) -> Option<String> {
|
||||||
|
let meta: Value = serde_json::from_str(meta_json).ok()?;
|
||||||
|
let item = library_item_from_meta(
|
||||||
|
&meta,
|
||||||
|
json!({
|
||||||
|
"lastWatched": Value::Null,
|
||||||
|
"timeOffset": 0,
|
||||||
|
"duration": 0,
|
||||||
|
"videoId": Value::Null,
|
||||||
|
"timesWatched": 0,
|
||||||
|
"flaggedWatched": 0
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
serde_json::to_string(&item).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn watched_state_items_json(
|
||||||
|
meta_json: &str,
|
||||||
|
episodes_json: &str,
|
||||||
|
watched: bool,
|
||||||
|
watched_at: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let meta: Value = serde_json::from_str(meta_json).ok()?;
|
||||||
|
let episodes: Vec<Value> = serde_json::from_str(episodes_json).unwrap_or_default();
|
||||||
|
let watched_value = if watched { 1 } else { 0 };
|
||||||
|
let watched_at_value = watched_at
|
||||||
|
.map(|value| Value::String(value.to_string()))
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
let items = if text(&meta, "type") == Some("series") && !episodes.is_empty() {
|
||||||
|
episodes
|
||||||
|
.iter()
|
||||||
|
.map(|episode| {
|
||||||
|
json!({
|
||||||
|
"_id": text(episode, "id").unwrap_or(""),
|
||||||
|
"name": text(episode, "name").or_else(|| text(&meta, "name")).unwrap_or(""),
|
||||||
|
"type": "series",
|
||||||
|
"poster": episode.get("thumbnail").cloned().unwrap_or(Value::Null),
|
||||||
|
"background": meta.get("background").cloned().unwrap_or(Value::Null),
|
||||||
|
"logo": meta.get("logo").cloned().unwrap_or(Value::Null),
|
||||||
|
"state": {
|
||||||
|
"lastWatched": watched_at_value,
|
||||||
|
"timeOffset": 0,
|
||||||
|
"duration": 0,
|
||||||
|
"videoId": text(episode, "id").unwrap_or(""),
|
||||||
|
"timesWatched": watched_value,
|
||||||
|
"flaggedWatched": watched_value
|
||||||
|
},
|
||||||
|
"lastWatched": watched_at_value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
} else {
|
||||||
|
vec![library_item_from_meta(
|
||||||
|
&meta,
|
||||||
|
json!({
|
||||||
|
"lastWatched": watched_at_value,
|
||||||
|
"timeOffset": 0,
|
||||||
|
"duration": 0,
|
||||||
|
"videoId": Value::Null,
|
||||||
|
"timesWatched": watched_value,
|
||||||
|
"flaggedWatched": watched_value
|
||||||
|
}),
|
||||||
|
watched_at,
|
||||||
|
)]
|
||||||
|
};
|
||||||
|
serde_json::to_string(&items).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn library_continue_watching_items_json(items_json: &str) -> Option<String> {
|
||||||
|
let mut items: Vec<Value> = serde_json::from_str(items_json).ok()?;
|
||||||
|
items.retain(|item| {
|
||||||
|
let state = item.get("state").unwrap_or(&Value::Null);
|
||||||
|
!state.is_null()
|
||||||
|
&& number(state, "timeOffset").unwrap_or(0) > 0
|
||||||
|
&& number(state, "flaggedWatched").unwrap_or(0) == 0
|
||||||
|
});
|
||||||
|
items.sort_by(|a, b| {
|
||||||
|
let a = a
|
||||||
|
.get("state")
|
||||||
|
.and_then(|state| text(state, "lastWatched"))
|
||||||
|
.unwrap_or("");
|
||||||
|
let b = b
|
||||||
|
.get("state")
|
||||||
|
.and_then(|state| text(state, "lastWatched"))
|
||||||
|
.unwrap_or("");
|
||||||
|
b.cmp(a)
|
||||||
|
});
|
||||||
|
let metas = items
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| {
|
||||||
|
let state = item.get("state").unwrap_or(&Value::Null);
|
||||||
|
json!({
|
||||||
|
"id": text(&item, "_id").unwrap_or(""),
|
||||||
|
"name": text(&item, "name").unwrap_or(""),
|
||||||
|
"type": text(&item, "type").unwrap_or(""),
|
||||||
|
"poster": item.get("poster").cloned().unwrap_or(Value::Null),
|
||||||
|
"background": item.get("background").cloned().unwrap_or(Value::Null),
|
||||||
|
"logo": item.get("logo").cloned().unwrap_or(Value::Null),
|
||||||
|
"description": Value::Null,
|
||||||
|
"timeOffset": number(state, "timeOffset"),
|
||||||
|
"duration": number(state, "duration"),
|
||||||
|
"lastVideoId": text(state, "videoId")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
serde_json::to_string(&metas).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn filter_home_continue_watching_json(
|
||||||
|
items_json: &str,
|
||||||
|
trakt_watched_json: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
|
||||||
|
let trakt: Value = serde_json::from_str(trakt_watched_json).unwrap_or(Value::Null);
|
||||||
|
|
||||||
|
let movie_keys: std::collections::HashSet<&str> = trakt
|
||||||
|
.get("movieKeys")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|arr| arr.iter().filter_map(Value::as_str).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let episode_keys: std::collections::HashSet<&str> = trakt
|
||||||
|
.get("episodeKeys")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|arr| arr.iter().filter_map(Value::as_str).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let filtered: Vec<&Value> = items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| {
|
||||||
|
let item_type = item.get("type").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let last_video_id = item.get("lastVideoId").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let time_offset = item.get("timeOffset").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let duration = item.get("duration").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let is_series = matches!(item_type, "series" | "tv" | "anime");
|
||||||
|
let is_up_next =
|
||||||
|
is_series && !last_video_id.is_empty() && time_offset <= 0 && duration <= 0;
|
||||||
|
let has_progress = time_offset > 0 && duration > 0;
|
||||||
|
if !is_up_next && !has_progress {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let watched_keys = crate::content_identity::content_watched_keys_value(item);
|
||||||
|
if item_type == "movie" && !movie_keys.is_empty() {
|
||||||
|
if watched_keys.iter().any(|k| movie_keys.contains(k.as_str())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_series && !episode_keys.is_empty() && !last_video_id.is_empty() {
|
||||||
|
if let Some((_, season, episode)) =
|
||||||
|
crate::content_identity::parse_episode_locator(last_video_id)
|
||||||
|
{
|
||||||
|
if watched_keys.iter().any(|k| {
|
||||||
|
let candidate = format!("{k}:{season}:{episode}");
|
||||||
|
episode_keys.contains(candidate.as_str())
|
||||||
|
}) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
serde_json::to_string(&filtered).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn watched_video_ids_json(items_json: &str, imdb_id: &str) -> Option<String> {
|
||||||
|
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
|
||||||
|
let ids = items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| {
|
||||||
|
text(item, "_id").is_some_and(|id| id.starts_with(imdb_id))
|
||||||
|
&& item
|
||||||
|
.get("state")
|
||||||
|
.and_then(|state| number(state, "flaggedWatched"))
|
||||||
|
== Some(1)
|
||||||
|
})
|
||||||
|
.filter_map(|item| text(item, "_id").map(str::to_string))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
serde_json::to_string(&ids).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalize_library_document_json(json: &str) -> String {
|
||||||
|
let mut lib: serde_json::Map<String, Value> = serde_json::from_str(json).unwrap_or_default();
|
||||||
|
lib.insert("schemaVersion".to_string(), json!(2));
|
||||||
|
if !lib.get("watchlist").map(Value::is_array).unwrap_or(false) {
|
||||||
|
lib.insert("watchlist".to_string(), json!([]));
|
||||||
|
}
|
||||||
|
if !lib.get("history").map(Value::is_array).unwrap_or(false) {
|
||||||
|
lib.insert("history".to_string(), json!([]));
|
||||||
|
}
|
||||||
|
if !lib.get("continueWatching").map(Value::is_array).unwrap_or(false) {
|
||||||
|
lib.insert("continueWatching".to_string(), json!([]));
|
||||||
|
}
|
||||||
|
if !lib.get("progress").map(|v| v.is_object() && !v.is_array()).unwrap_or(false) {
|
||||||
|
lib.insert("progress".to_string(), json!({}));
|
||||||
|
}
|
||||||
|
if !lib.get("watched").map(|v| v.is_object() && !v.is_array()).unwrap_or(false) {
|
||||||
|
lib.insert("watched".to_string(), json!({}));
|
||||||
|
}
|
||||||
|
serde_json::to_string(&Value::Object(lib)).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_up_next_continue_watching_item_json(item_json: &str) -> bool {
|
||||||
|
let item: Value = serde_json::from_str(item_json).unwrap_or(Value::Null);
|
||||||
|
is_up_next_item(&item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_up_next_item(item: &Value) -> bool {
|
||||||
|
let offset = item.get("timeOffset").and_then(Value::as_f64).unwrap_or(0.0);
|
||||||
|
let duration = item.get("duration").and_then(Value::as_f64).unwrap_or(0.0);
|
||||||
|
if duration <= 0.0 { return offset <= 1.0; }
|
||||||
|
let progress = offset / duration;
|
||||||
|
progress < 0.005 || progress >= 0.995
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_continue_watching_from_progress_json(progress_json: &str) -> Option<String> {
|
||||||
|
let progress: serde_json::Map<String, Value> = serde_json::from_str(progress_json).ok()?;
|
||||||
|
let mut items: Vec<Value> = progress.values()
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let offset = entry.get("timeOffset").and_then(Value::as_f64).unwrap_or(0.0);
|
||||||
|
let duration = entry.get("duration").and_then(Value::as_f64).unwrap_or(0.0);
|
||||||
|
let has_video_id = entry.get("lastVideoId").and_then(Value::as_str).filter(|s| !s.is_empty()).is_some();
|
||||||
|
// Include: items with real progress OR up-next entries (offset=0 but has lastVideoId)
|
||||||
|
let include = (offset > 0.0 && duration > 0.0 && offset / duration < 0.95)
|
||||||
|
|| (offset == 0.0 && has_video_id);
|
||||||
|
if !include { return None; }
|
||||||
|
let meta = entry.get("meta")?;
|
||||||
|
let id = meta.get("id").and_then(Value::as_str).unwrap_or("");
|
||||||
|
if id.is_empty() { return None; }
|
||||||
|
Some(json!({
|
||||||
|
"id": id,
|
||||||
|
"name": meta.get("name").and_then(Value::as_str).unwrap_or(""),
|
||||||
|
"type": meta.get("type").and_then(Value::as_str).unwrap_or(""),
|
||||||
|
"poster": meta.get("poster").cloned().unwrap_or(Value::Null),
|
||||||
|
"background": meta.get("background").cloned().unwrap_or(Value::Null),
|
||||||
|
"logo": meta.get("logo").cloned().unwrap_or(Value::Null),
|
||||||
|
"timeOffset": offset as i64,
|
||||||
|
"duration": duration as i64,
|
||||||
|
"lastVideoId": entry.get("lastVideoId").cloned().unwrap_or(Value::Null),
|
||||||
|
"lastEpisodeName": entry.get("lastEpisodeName").cloned().unwrap_or(Value::Null),
|
||||||
|
"lastEpisodeSeason": entry.get("lastEpisodeSeason").cloned().unwrap_or(Value::Null),
|
||||||
|
"lastEpisodeNumber": entry.get("lastEpisodeNumber").cloned().unwrap_or(Value::Null),
|
||||||
|
"lastEpisodeThumbnail": entry.get("lastEpisodeThumbnail").cloned().unwrap_or(Value::Null),
|
||||||
|
"lastStreamUrl": entry.get("lastStreamUrl").cloned().unwrap_or(Value::Null),
|
||||||
|
"lastStreamTitle": entry.get("lastStreamTitle").cloned().unwrap_or(Value::Null),
|
||||||
|
"lastStream": entry.get("lastStream").cloned().unwrap_or(Value::Null),
|
||||||
|
"savedAt": entry.get("savedAt").cloned().unwrap_or(Value::Null),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
items.sort_by(|a, b| {
|
||||||
|
let a = a.get("savedAt").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let b = b.get("savedAt").and_then(Value::as_str).unwrap_or("");
|
||||||
|
b.cmp(a)
|
||||||
|
});
|
||||||
|
serde_json::to_string(&items).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn compute_continue_watching_badges_json(
|
||||||
|
candidates_json: &str,
|
||||||
|
videos_by_series_json: &str,
|
||||||
|
last_watched_json: &str,
|
||||||
|
now_ms: i64,
|
||||||
|
) -> Option<String> {
|
||||||
|
let mut by_id: std::collections::HashMap<String, Value> = {
|
||||||
|
let items: Vec<Value> = serde_json::from_str(candidates_json).unwrap_or_default();
|
||||||
|
items.into_iter().filter_map(|item| {
|
||||||
|
let id = item.get("id").or_else(|| item.get("_id"))
|
||||||
|
.and_then(Value::as_str).map(str::to_string)?;
|
||||||
|
Some((id, item))
|
||||||
|
}).collect()
|
||||||
|
};
|
||||||
|
let videos_by_series: serde_json::Map<String, Value> =
|
||||||
|
serde_json::from_str(videos_by_series_json).unwrap_or_default();
|
||||||
|
let last_watched: serde_json::Map<String, Value> =
|
||||||
|
serde_json::from_str(last_watched_json).unwrap_or_default();
|
||||||
|
|
||||||
|
// Track which IDs came from the real CW lists vs only from lastWatchedEpisodes.
|
||||||
|
// Candidates added only from lastWatchedEpisodes are removed when no video data
|
||||||
|
// is available to confirm a next episode exists, preventing phantom CW entries.
|
||||||
|
let cw_list_ids: std::collections::HashSet<String> = by_id.keys().cloned().collect();
|
||||||
|
|
||||||
|
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 record = raw;
|
||||||
|
by_id.entry(series_id.clone()).or_insert_with(|| json!({
|
||||||
|
"id": series_id,
|
||||||
|
"_id": series_id,
|
||||||
|
"type": "series",
|
||||||
|
"name": meta.get("name").cloned().unwrap_or(Value::Null),
|
||||||
|
"poster": meta.get("poster").cloned().unwrap_or(Value::Null),
|
||||||
|
"background": meta.get("background").cloned().unwrap_or(Value::Null),
|
||||||
|
"logo": meta.get("logo").cloned().unwrap_or(Value::Null),
|
||||||
|
"lastVideoId": record.get("lastVideoId").cloned().unwrap_or(Value::Null),
|
||||||
|
"lastEpisodeName": record.get("lastEpisodeName").cloned().unwrap_or(Value::Null),
|
||||||
|
"lastEpisodeSeason": record.get("lastEpisodeSeason").cloned().unwrap_or(Value::Null),
|
||||||
|
"lastEpisodeNumber": record.get("lastEpisodeNumber").cloned().unwrap_or(Value::Null),
|
||||||
|
"lastEpisodeThumbnail": record.get("lastEpisodeThumbnail").cloned().unwrap_or(Value::Null),
|
||||||
|
"timeOffset": 1,
|
||||||
|
"duration": 99999,
|
||||||
|
"savedAt": record.get("watchedAt").cloned().unwrap_or(Value::Null),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut finished_series: Vec<String> = Vec::new();
|
||||||
|
for (series_id, candidate) in by_id.iter_mut() {
|
||||||
|
if candidate.get("type").and_then(Value::as_str) != Some("series") { continue; }
|
||||||
|
if !is_up_next_item(candidate) { continue; }
|
||||||
|
let season = match candidate.get("lastEpisodeSeason").and_then(Value::as_i64) { Some(s) => s, None => continue };
|
||||||
|
let episode = match candidate.get("lastEpisodeNumber").and_then(Value::as_i64) { Some(e) => e, None => continue };
|
||||||
|
let videos = match videos_by_series.get(series_id).and_then(Value::as_array) {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
// No video data available. If this entry exists only because of
|
||||||
|
// lastWatchedEpisodes (not from any real CW list), conservatively
|
||||||
|
// remove it — we cannot confirm a next episode exists. It will
|
||||||
|
// reappear on the next home load once the addon responds.
|
||||||
|
if !cw_list_ids.contains(series_id) {
|
||||||
|
finished_series.push(series_id.clone());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let stored_badge = candidate.get("continueWatchingBadge").and_then(Value::as_str);
|
||||||
|
let stored_video_id = candidate.get("lastVideoId").and_then(Value::as_str).unwrap_or("").to_string();
|
||||||
|
|
||||||
|
// When the stored badge is scheduledEpisode, lastEpisodeNumber already points to the
|
||||||
|
// scheduled episode itself. Re-check that same episode rather than advancing past it.
|
||||||
|
let next = if stored_badge == Some("scheduledEpisode") {
|
||||||
|
videos.iter().find(|v| {
|
||||||
|
let vid = v.get("id").or_else(|| v.get("_id")).and_then(Value::as_str).unwrap_or("");
|
||||||
|
vid == stored_video_id
|
||||||
|
}).cloned().or_else(|| first_episode_after(videos, season, episode))
|
||||||
|
} else {
|
||||||
|
first_episode_after(videos, season, episode)
|
||||||
|
};
|
||||||
|
// No next episode and we have real video data — the series is fully watched.
|
||||||
|
// Remove it from Continue Watching instead of leaving a zombie entry.
|
||||||
|
let next = match next {
|
||||||
|
Some(v) => v,
|
||||||
|
None => { finished_series.push(series_id.clone()); continue; }
|
||||||
|
};
|
||||||
|
|
||||||
|
let existing_video_id = stored_video_id;
|
||||||
|
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 { continue; }
|
||||||
|
let is_new_target = existing_video_id != next_id;
|
||||||
|
let is_released = is_episode_released(&next, now_ms);
|
||||||
|
let existing_badge = if !is_new_target { candidate.get("continueWatchingBadge").and_then(Value::as_str).map(str::to_string) } else { None };
|
||||||
|
|
||||||
|
let badge = if !is_released {
|
||||||
|
"scheduledEpisode"
|
||||||
|
} else if existing_badge.as_deref() == Some("scheduledEpisode") {
|
||||||
|
"newEpisode"
|
||||||
|
} else if existing_badge.is_some() {
|
||||||
|
existing_badge.as_deref().unwrap()
|
||||||
|
} else {
|
||||||
|
let watched_at = candidate.get("savedAt").and_then(Value::as_str)
|
||||||
|
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||||
|
.map(|dt| dt.timestamp_millis()).unwrap_or(now_ms);
|
||||||
|
let next_released_at = next.get("released").and_then(Value::as_str)
|
||||||
|
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||||
|
.map(|dt| dt.timestamp_millis()).unwrap_or(0);
|
||||||
|
let was_released_when_watched = next.get("released").is_none() || next_released_at <= watched_at;
|
||||||
|
if was_released_when_watched { "upNext" } else { "newEpisode" }
|
||||||
|
}.to_string();
|
||||||
|
|
||||||
|
let released_str = next.get("released").and_then(Value::as_str)
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
|
||||||
|
let saved_at_new = if is_new_target && badge == "newEpisode" {
|
||||||
|
Value::String(chrono::Utc::now().to_rfc3339())
|
||||||
|
} else {
|
||||||
|
candidate.get("savedAt").cloned().unwrap_or(Value::Null)
|
||||||
|
};
|
||||||
|
|
||||||
|
*candidate = json!({
|
||||||
|
"id": series_id,
|
||||||
|
"_id": series_id,
|
||||||
|
"type": "series",
|
||||||
|
"name": candidate.get("name").cloned().unwrap_or(Value::Null),
|
||||||
|
"poster": candidate.get("poster").cloned().unwrap_or(Value::Null),
|
||||||
|
"background": candidate.get("background").cloned().unwrap_or(Value::Null),
|
||||||
|
"logo": candidate.get("logo").cloned().unwrap_or(Value::Null),
|
||||||
|
"timeOffset": 1,
|
||||||
|
"duration": 99999,
|
||||||
|
"lastVideoId": next_id,
|
||||||
|
"lastEpisodeName": next.get("name").or_else(|| next.get("title")).cloned().unwrap_or(Value::Null),
|
||||||
|
"lastEpisodeSeason": next.get("season").cloned().unwrap_or(Value::Null),
|
||||||
|
"lastEpisodeNumber": next.get("episode").or_else(|| next.get("number")).cloned().unwrap_or(Value::Null),
|
||||||
|
"lastEpisodeThumbnail": next.get("thumbnail").cloned().unwrap_or(Value::Null),
|
||||||
|
"continueWatchingBadge": badge,
|
||||||
|
"newEpisodeReleasedAt": released_str,
|
||||||
|
"savedAt": saved_at_new,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for id in &finished_series { by_id.remove(id); }
|
||||||
|
let mut result: Vec<Value> = by_id.into_values().collect();
|
||||||
|
result.sort_by(|a, b| {
|
||||||
|
let a_new = a.get("continueWatchingBadge").and_then(Value::as_str) == Some("newEpisode");
|
||||||
|
let b_new = b.get("continueWatchingBadge").and_then(Value::as_str) == Some("newEpisode");
|
||||||
|
if a_new != b_new { return if a_new { std::cmp::Ordering::Less } else { std::cmp::Ordering::Greater }; }
|
||||||
|
let a_time = a.get("savedAt").or_else(|| a.get("newEpisodeReleasedAt")).and_then(Value::as_str).unwrap_or("");
|
||||||
|
let b_time = b.get("savedAt").or_else(|| b.get("newEpisodeReleasedAt")).and_then(Value::as_str).unwrap_or("");
|
||||||
|
b_time.cmp(a_time)
|
||||||
|
});
|
||||||
|
serde_json::to_string(&result).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_episode_after(videos: &[Value], season: i64, episode: i64) -> Option<Value> {
|
||||||
|
let mut candidates: Vec<&Value> = videos.iter().filter(|v| {
|
||||||
|
let vs = v.get("season").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let ve = v.get("episode").or_else(|| v.get("number")).and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
vs > season || (vs == season && ve > episode)
|
||||||
|
}).collect();
|
||||||
|
candidates.sort_by(|a, b| {
|
||||||
|
let as_ = a.get("season").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let bs = b.get("season").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
if as_ != bs { return as_.cmp(&bs); }
|
||||||
|
let ae = a.get("episode").or_else(|| a.get("number")).and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let be = b.get("episode").or_else(|| b.get("number")).and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
ae.cmp(&be)
|
||||||
|
});
|
||||||
|
candidates.first().map(|v| (*v).clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_episode_released(video: &Value, now_ms: i64) -> bool {
|
||||||
|
let released = match video.get("released").and_then(Value::as_str) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return true,
|
||||||
|
};
|
||||||
|
match chrono::DateTime::parse_from_rfc3339(released) {
|
||||||
|
Ok(dt) => dt.timestamp_millis() <= now_ms,
|
||||||
|
Err(_) => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Given a library JSON and a set of just-watched video IDs, update `lastWatchedEpisodes`.
|
||||||
|
/// Returns the updated library as JSON.
|
||||||
|
pub(crate) fn remember_last_watched_episodes_json(lib_json: &str, watched_ids_json: &str) -> String {
|
||||||
|
let mut lib: Value = serde_json::from_str(lib_json).unwrap_or(json!({}));
|
||||||
|
let watched_ids: std::collections::HashSet<String> = serde_json::from_str(watched_ids_json)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v: Value| v.as_array().map(|arr| {
|
||||||
|
arr.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()
|
||||||
|
}))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let progress = lib.get("progress").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||||
|
let mut last_watched = lib.get("lastWatchedEpisodes").and_then(Value::as_object).cloned().unwrap_or_default();
|
||||||
|
for (series_id, raw) in &progress {
|
||||||
|
let video_id = raw.get("lastVideoId").and_then(Value::as_str).unwrap_or("");
|
||||||
|
if video_id.is_empty() || !watched_ids.contains(video_id) { continue; }
|
||||||
|
let meta = match raw.get("meta") { Some(m) if m.get("type").and_then(Value::as_str) == Some("series") => m, _ => continue };
|
||||||
|
last_watched.insert(series_id.clone(), json!({
|
||||||
|
"meta": meta,
|
||||||
|
"lastVideoId": video_id,
|
||||||
|
"lastEpisodeName": raw.get("lastEpisodeName").cloned().unwrap_or(Value::Null),
|
||||||
|
"lastEpisodeSeason": raw.get("lastEpisodeSeason").cloned().unwrap_or(Value::Null),
|
||||||
|
"lastEpisodeNumber": raw.get("lastEpisodeNumber").cloned().unwrap_or(Value::Null),
|
||||||
|
"lastEpisodeThumbnail": raw.get("lastEpisodeThumbnail").cloned().unwrap_or(Value::Null),
|
||||||
|
"watchedAt": chrono::Utc::now().to_rfc3339(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if let Some(obj) = lib.as_object_mut() {
|
||||||
|
obj.insert("lastWatchedEpisodes".to_string(), Value::Object(last_watched));
|
||||||
|
}
|
||||||
|
serde_json::to_string(&lib).unwrap_or_else(|_| lib_json.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn watched_state_items_build_series_episode_payloads() {
|
||||||
|
let items = watched_state_items_json(
|
||||||
|
r#"{"id":"tt1","name":"Show","type":"series","poster":null,"background":"bg","logo":"logo"}"#,
|
||||||
|
r#"[{"id":"tt1:1:2","name":null,"season":1,"number":2,"released":null,"thumbnail":"ep.jpg"}]"#,
|
||||||
|
true,
|
||||||
|
Some("2026-01-01T00:00:00.000Z"),
|
||||||
|
)
|
||||||
|
.and_then(|json| serde_json::from_str::<Value>(&json).ok())
|
||||||
|
.expect("items");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
items
|
||||||
|
.get(0)
|
||||||
|
.and_then(|item| item.get("_id"))
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("tt1:1:2")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
items
|
||||||
|
.get(0)
|
||||||
|
.and_then(|item| item.get("state"))
|
||||||
|
.and_then(|state| state.get("flaggedWatched"))
|
||||||
|
.and_then(Value::as_i64),
|
||||||
|
Some(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
259
src/offline_download.rs
Normal file
259
src/offline_download.rs
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
use crate::stream_policy::{
|
||||||
|
is_torrent_playback_url, stream_effective_filename, stream_playable_url, stream_text,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct OfflineDownloadPlanRequest {
|
||||||
|
meta: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
video: Option<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
video_id: Option<String>,
|
||||||
|
stream: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
subtitle_url: Option<String>,
|
||||||
|
download_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct OfflineDownloadPlan {
|
||||||
|
supported: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
reason: Option<&'static str>,
|
||||||
|
playback_url: String,
|
||||||
|
base_name: String,
|
||||||
|
video_file_name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
subtitle_file_name: Option<String>,
|
||||||
|
poster_file_name: String,
|
||||||
|
background_file_name: String,
|
||||||
|
logo_file_name: String,
|
||||||
|
video_id: Option<String>,
|
||||||
|
stream_title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn offline_download_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request: OfflineDownloadPlanRequest = serde_json::from_str(request_json).ok()?;
|
||||||
|
serde_json::to_string(&offline_download_plan(request)).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offline_download_plan(request: OfflineDownloadPlanRequest) -> OfflineDownloadPlan {
|
||||||
|
let playback_url = stream_playable_url(&request.stream).unwrap_or_default();
|
||||||
|
let effective_filename = stream_effective_filename(&request.stream, Some(&playback_url));
|
||||||
|
let support_error = downloadable_error(&playback_url, &request.stream);
|
||||||
|
let base_name = offline_base_name(&request.meta, request.video.as_ref(), &request.download_id);
|
||||||
|
let extension = file_extension(&playback_url, effective_filename.as_deref());
|
||||||
|
let stream_title = raw_stream_display_title(&request.stream, &playback_url);
|
||||||
|
let subtitle_extension = request
|
||||||
|
.subtitle_url
|
||||||
|
.as_deref()
|
||||||
|
.map(subtitle_extension)
|
||||||
|
.unwrap_or_else(|| "srt".to_string());
|
||||||
|
|
||||||
|
OfflineDownloadPlan {
|
||||||
|
supported: support_error.is_none(),
|
||||||
|
reason: support_error,
|
||||||
|
playback_url,
|
||||||
|
video_file_name: format!("{base_name}-{}.{extension}", request.download_id),
|
||||||
|
subtitle_file_name: request
|
||||||
|
.subtitle_url
|
||||||
|
.as_ref()
|
||||||
|
.map(|_| format!("{base_name}-{}.{}", request.download_id, subtitle_extension)),
|
||||||
|
poster_file_name: format!("{base_name}-{}-poster.jpg", request.download_id),
|
||||||
|
background_file_name: format!("{base_name}-{}-background.jpg", request.download_id),
|
||||||
|
logo_file_name: format!("{base_name}-{}-logo.png", request.download_id),
|
||||||
|
video_id: request
|
||||||
|
.video
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|video| text(video, "id").map(ToOwned::to_owned))
|
||||||
|
.or(request.video_id),
|
||||||
|
stream_title,
|
||||||
|
base_name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn downloadable_error(url: &str, stream: &Value) -> Option<&'static str> {
|
||||||
|
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||||
|
return Some("unsupported_source");
|
||||||
|
}
|
||||||
|
if is_torrent_playback_url(url) {
|
||||||
|
return Some("unsupported_source");
|
||||||
|
}
|
||||||
|
let normalized_path = url.split('?').next().unwrap_or(url).to_lowercase();
|
||||||
|
if [".srt", ".vtt", ".ass", ".ssa", ".ttml", ".sub"]
|
||||||
|
.iter()
|
||||||
|
.any(|ext| normalized_path.ends_with(ext))
|
||||||
|
{
|
||||||
|
return Some("unsupported_source");
|
||||||
|
}
|
||||||
|
if [".m3u8", ".mpd"]
|
||||||
|
.iter()
|
||||||
|
.any(|ext| normalized_path.ends_with(ext))
|
||||||
|
{
|
||||||
|
return Some("unsupported_source");
|
||||||
|
}
|
||||||
|
let source_text = ["name", "title", "description", "addonName"]
|
||||||
|
.iter()
|
||||||
|
.filter_map(|key| stream_text(stream, key))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
.to_lowercase();
|
||||||
|
if source_text.contains("opensubtitles")
|
||||||
|
|| source_text.contains("subtitle")
|
||||||
|
|| source_text.contains("altyazi")
|
||||||
|
|| source_text.contains("altyazı")
|
||||||
|
{
|
||||||
|
return Some("unsupported_source");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn offline_base_name(meta: &Value, video: Option<&Value>, fallback: &str) -> String {
|
||||||
|
let mut parts = vec![];
|
||||||
|
if let Some(name) = text(meta, "name") {
|
||||||
|
parts.push(name.to_string());
|
||||||
|
}
|
||||||
|
if let Some(season) = video.and_then(|v| number(v, "season")) {
|
||||||
|
parts.push(format!("S{season}"));
|
||||||
|
}
|
||||||
|
if let Some(episode) = video.and_then(|v| number(v, "number")) {
|
||||||
|
parts.push(format!("E{episode}"));
|
||||||
|
}
|
||||||
|
let sanitized = sanitize_file_name(&parts.join(" "));
|
||||||
|
if sanitized.is_empty() {
|
||||||
|
fallback.to_string()
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_file_name(value: &str) -> String {
|
||||||
|
let mut output = String::with_capacity(value.len());
|
||||||
|
let mut previous_space = false;
|
||||||
|
for character in value.chars() {
|
||||||
|
let allowed =
|
||||||
|
character.is_ascii_alphanumeric() || matches!(character, '_' | '-' | '.' | ' ');
|
||||||
|
let next = if allowed { character } else { ' ' };
|
||||||
|
if next.is_whitespace() {
|
||||||
|
if !previous_space {
|
||||||
|
output.push(' ');
|
||||||
|
}
|
||||||
|
previous_space = true;
|
||||||
|
} else {
|
||||||
|
output.push(next);
|
||||||
|
previous_space = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.trim().chars().take(80).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_extension(url: &str, filename: Option<&str>) -> String {
|
||||||
|
if let Some(ext) = filename
|
||||||
|
.and_then(extension_part)
|
||||||
|
.filter(|ext| !ext.is_empty() && ext.len() <= 5)
|
||||||
|
{
|
||||||
|
return ext;
|
||||||
|
}
|
||||||
|
extension_part(url)
|
||||||
|
.filter(|ext| (2..=5).contains(&ext.len()))
|
||||||
|
.unwrap_or_else(|| "mp4".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subtitle_extension(url: &str) -> String {
|
||||||
|
let ext = extension_part(url).unwrap_or_default();
|
||||||
|
if matches!(ext.as_str(), "srt" | "vtt" | "ass" | "ssa" | "ttml") {
|
||||||
|
ext
|
||||||
|
} else {
|
||||||
|
"srt".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extension_part(value: &str) -> Option<String> {
|
||||||
|
let path = value.split('?').next().unwrap_or(value);
|
||||||
|
path.rsplit('.').next().and_then(|ext| {
|
||||||
|
if ext == path {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(ext.to_lowercase())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn raw_stream_display_title(stream: &Value, playable_url: &str) -> Option<String> {
|
||||||
|
stream_text(stream, "name")
|
||||||
|
.or_else(|| stream_text(stream, "title"))
|
||||||
|
.or_else(|| stream_text(stream, "description"))
|
||||||
|
.or_else(|| stream_text(stream, "addonName"))
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| {
|
||||||
|
if playable_url.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(playable_url.to_string())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
|
||||||
|
value
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number(value: &Value, key: &str) -> Option<i64> {
|
||||||
|
value.get(key).and_then(Value::as_i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plans_downloadable_http_streams() {
|
||||||
|
let request = json!({
|
||||||
|
"downloadId": "id1",
|
||||||
|
"meta": {"id": "tt1", "name": "Movie: Name", "type": "movie"},
|
||||||
|
"video": {"id": "tt1:1:2", "season": 1, "number": 2},
|
||||||
|
"stream": {"url": "https://cdn.example/video.mkv", "title": "1080p"},
|
||||||
|
"subtitleUrl": "https://sub.example/file.vtt"
|
||||||
|
});
|
||||||
|
|
||||||
|
let value: Value =
|
||||||
|
serde_json::from_str(&offline_download_plan_json(&request.to_string()).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["supported"], true);
|
||||||
|
assert_eq!(value["playbackUrl"], "https://cdn.example/video.mkv");
|
||||||
|
assert_eq!(value["videoFileName"], "Movie Name S1 E2-id1.mkv");
|
||||||
|
assert_eq!(value["subtitleFileName"], "Movie Name S1 E2-id1.vtt");
|
||||||
|
assert_eq!(value["videoId"], "tt1:1:2");
|
||||||
|
assert_eq!(value["streamTitle"], "1080p");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_torrents_manifests_and_subtitle_sources() {
|
||||||
|
for stream in [
|
||||||
|
json!({"url": "magnet:?xt=urn:btih:abc"}),
|
||||||
|
json!({"url": "https://cdn.example/list.m3u8"}),
|
||||||
|
json!({"url": "https://cdn.example/file.srt"}),
|
||||||
|
json!({"url": "https://cdn.example/video.mp4", "addonName": "OpenSubtitles"}),
|
||||||
|
] {
|
||||||
|
let request = json!({
|
||||||
|
"downloadId": "id1",
|
||||||
|
"meta": {"name": "Movie"},
|
||||||
|
"stream": stream
|
||||||
|
});
|
||||||
|
let value: Value =
|
||||||
|
serde_json::from_str(&offline_download_plan_json(&request.to_string()).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(value["supported"], false);
|
||||||
|
assert_eq!(value["reason"], "unsupported_source");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
654
src/platform_plan.rs
Normal file
654
src/platform_plan.rs
Normal file
|
|
@ -0,0 +1,654 @@
|
||||||
|
use crate::addon_protocol::{build_resource_url, supports_resource};
|
||||||
|
use crate::content_identity::parse_extra_args_json;
|
||||||
|
use crate::repository_flow::addon_streams_with_provider_json;
|
||||||
|
use crate::stream_policy::stream_playback_info_json;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ResourceFetchPlanRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
kind: String,
|
||||||
|
#[serde(default)]
|
||||||
|
addons: Vec<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
transport_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
resource: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
content_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
catalog_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
request_ids: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
extra: Map<String, Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
extra_raw: String,
|
||||||
|
#[serde(default)]
|
||||||
|
query: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
genre: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
skip: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ResourceParseRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
kind: String,
|
||||||
|
#[serde(default)]
|
||||||
|
response: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
addon_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
season: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct PlaybackPrepareRequest {
|
||||||
|
stream: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
meta: Option<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
episode: Option<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
preferred_player: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LibraryLocalStateRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
library: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
primary_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
fallback_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct PreferenceUpdateRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
existing: Map<String, Value>,
|
||||||
|
key: String,
|
||||||
|
value: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct AddonCollectionMutationRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
existing: Vec<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
incoming: Vec<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
remove_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DetailEpisodePlanRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
episodes: Vec<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
selected_season: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
selected_episode_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
meta_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resource_fetch_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<ResourceFetchPlanRequest>(request_json).ok()?;
|
||||||
|
let mut requests = Vec::<Value>::new();
|
||||||
|
|
||||||
|
match request.kind.as_str() {
|
||||||
|
"catalogPage" => {
|
||||||
|
let transport_url = request.transport_url.as_deref()?;
|
||||||
|
let content_type = request.content_type.as_deref()?;
|
||||||
|
let catalog_id = request.catalog_id.as_deref()?;
|
||||||
|
requests.push(json!({
|
||||||
|
"url": build_resource_url(transport_url, "catalog", content_type, catalog_id, extra_json(&request).as_deref()),
|
||||||
|
"kind": "catalogPage"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
"search" => {
|
||||||
|
let query = request.query.as_deref().unwrap_or("");
|
||||||
|
for addon in &request.addons {
|
||||||
|
let Some(transport_url) = addon_transport_url(addon) else { continue };
|
||||||
|
for catalog in addon_catalogs(addon) {
|
||||||
|
if !catalog_supports_search(&catalog) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(content_type) = catalog.get("type").and_then(Value::as_str) else { continue };
|
||||||
|
let Some(id) = catalog.get("id").and_then(Value::as_str) else { continue };
|
||||||
|
requests.push(json!({
|
||||||
|
"url": build_resource_url(transport_url, "catalog", content_type, id, Some(&json!({"search": query}).to_string())),
|
||||||
|
"kind": "search",
|
||||||
|
"addonName": addon_display_name(addon),
|
||||||
|
"catalogId": id,
|
||||||
|
"catalogType": content_type,
|
||||||
|
"categoryId": format!("{}:{}:{}", transport_url, content_type, id),
|
||||||
|
"categoryName": search_category_name(addon, &catalog, content_type)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"discover" => {
|
||||||
|
let genre = request.genre.as_deref();
|
||||||
|
for catalog in discover_catalog_options(&request.addons, request.content_type.as_deref().unwrap_or("")) {
|
||||||
|
let extra = genre.map(|value| json!({"genre": value}).to_string());
|
||||||
|
requests.push(json!({
|
||||||
|
"url": build_resource_url(
|
||||||
|
&catalog.transport_url,
|
||||||
|
"catalog",
|
||||||
|
&catalog.content_type,
|
||||||
|
&catalog.id,
|
||||||
|
extra.as_deref()
|
||||||
|
),
|
||||||
|
"kind": "discover",
|
||||||
|
"catalogKey": catalog.key
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"metaDetail" => {
|
||||||
|
let content_type = request.content_type.as_deref()?;
|
||||||
|
let id = request.id.as_deref()?;
|
||||||
|
for addon in &request.addons {
|
||||||
|
if !addon_supports(addon, "meta", content_type, Some(id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(transport_url) = addon_transport_url(addon) else { continue };
|
||||||
|
requests.push(json!({
|
||||||
|
"url": build_resource_url(transport_url, "meta", content_type, id, None),
|
||||||
|
"kind": "metaDetail",
|
||||||
|
"addonName": addon_display_name(addon),
|
||||||
|
"stopOnFirstResult": true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"streams" => {
|
||||||
|
let content_type = request.content_type.as_deref()?;
|
||||||
|
for addon in &request.addons {
|
||||||
|
if !addon_supports(addon, "stream", content_type, None) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(transport_url) = addon_transport_url(addon) else { continue };
|
||||||
|
for id in &request.request_ids {
|
||||||
|
requests.push(json!({
|
||||||
|
"url": build_resource_url(transport_url, "stream", content_type, id, None),
|
||||||
|
"kind": "streams",
|
||||||
|
"addonName": addon_display_name(addon)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"seasonEpisodes" => {
|
||||||
|
let series_id = request.id.as_deref()?;
|
||||||
|
for addon in &request.addons {
|
||||||
|
if !addon_supports(addon, "meta", "series", Some(series_id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(transport_url) = addon_transport_url(addon) else { continue };
|
||||||
|
requests.push(json!({
|
||||||
|
"url": build_resource_url(transport_url, "meta", "series", series_id, None),
|
||||||
|
"kind": "seasonEpisodes",
|
||||||
|
"addonName": addon_display_name(addon),
|
||||||
|
"stopOnFirstResult": true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"subtitles" => {
|
||||||
|
let content_type = request.content_type.as_deref()?;
|
||||||
|
let id = request.id.as_deref()?;
|
||||||
|
for addon in &request.addons {
|
||||||
|
if !addon_supports(addon, "subtitles", content_type, Some(id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(transport_url) = addon_transport_url(addon) else { continue };
|
||||||
|
requests.push(json!({
|
||||||
|
"url": build_resource_url(transport_url, "subtitles", content_type, id, None),
|
||||||
|
"kind": "subtitles",
|
||||||
|
"addonName": addon_display_name(addon)
|
||||||
|
}));
|
||||||
|
if !request.extra_raw.trim().is_empty() {
|
||||||
|
requests.push(json!({
|
||||||
|
"url": build_resource_url(
|
||||||
|
transport_url,
|
||||||
|
"subtitles",
|
||||||
|
content_type,
|
||||||
|
id,
|
||||||
|
parse_extra_args_json(&request.extra_raw).as_deref()
|
||||||
|
),
|
||||||
|
"kind": "subtitles",
|
||||||
|
"addonName": addon_display_name(addon)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let transport_url = request.transport_url.as_deref()?;
|
||||||
|
let resource = request.resource.as_deref()?;
|
||||||
|
let content_type = request.content_type.as_deref()?;
|
||||||
|
let id = request.id.as_deref()?;
|
||||||
|
requests.push(json!({
|
||||||
|
"url": build_resource_url(transport_url, resource, content_type, id, extra_json(&request).as_deref()),
|
||||||
|
"kind": request.kind
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::to_string(&json!({ "requests": requests })).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resource_parse_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<ResourceParseRequest>(request_json).ok()?;
|
||||||
|
let response = request.response;
|
||||||
|
let value = match request.kind.as_str() {
|
||||||
|
"catalogPage" | "discover" | "search" => {
|
||||||
|
json!({ "items": response.get("metas").and_then(Value::as_array).cloned().unwrap_or_default() })
|
||||||
|
}
|
||||||
|
"metaDetail" => json!({ "meta": response.get("meta").cloned().unwrap_or(Value::Null) }),
|
||||||
|
"streams" => {
|
||||||
|
let streams = response
|
||||||
|
.get("streams")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let normalized = addon_streams_with_provider_json(
|
||||||
|
&Value::Array(streams).to_string(),
|
||||||
|
request.addon_name.as_deref().unwrap_or(""),
|
||||||
|
);
|
||||||
|
json!({ "streams": serde_json::from_str::<Value>(&normalized).unwrap_or(Value::Array(vec![])) })
|
||||||
|
}
|
||||||
|
"seasonEpisodes" => {
|
||||||
|
let videos = response
|
||||||
|
.get("meta")
|
||||||
|
.and_then(|meta| meta.get("videos"))
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|video| {
|
||||||
|
request.season.is_none()
|
||||||
|
|| video.get("season").and_then(Value::as_i64) == request.season
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
json!({ "episodes": videos })
|
||||||
|
}
|
||||||
|
"subtitles" => {
|
||||||
|
json!({ "subtitles": response.get("subtitles").and_then(Value::as_array).cloned().unwrap_or_default() })
|
||||||
|
}
|
||||||
|
_ => response,
|
||||||
|
};
|
||||||
|
serde_json::to_string(&value).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn playback_prepare_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<PlaybackPrepareRequest>(request_json).ok()?;
|
||||||
|
let info = stream_playback_info_json(&request.stream.to_string())
|
||||||
|
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
let playable_url = info
|
||||||
|
.get("playableUrl")
|
||||||
|
.or_else(|| request.stream.get("playableUrl"))
|
||||||
|
.or_else(|| request.stream.get("url"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let is_torrent = info
|
||||||
|
.get("isTorrentPlaybackUrl")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| playable_url.starts_with("stremio://torrent/")
|
||||||
|
|| request.stream.get("infoHash").and_then(Value::as_str).is_some();
|
||||||
|
let compatible = info
|
||||||
|
.get("isLikelyPlayerCompatible")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(true);
|
||||||
|
let mode = if playable_url.is_empty() {
|
||||||
|
"reject"
|
||||||
|
} else if !compatible {
|
||||||
|
"reject"
|
||||||
|
} else if is_torrent {
|
||||||
|
"torrent"
|
||||||
|
} else {
|
||||||
|
"direct"
|
||||||
|
};
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"mode": mode,
|
||||||
|
"url": playable_url,
|
||||||
|
"isTorrent": is_torrent,
|
||||||
|
"rejectReason": if playable_url.is_empty() { "missing_playable_url" } else if !compatible { "incompatible_stream" } else { "" },
|
||||||
|
"subtitleExtraArgs": info.get("subtitleExtraArgs").cloned().unwrap_or(Value::Null),
|
||||||
|
"title": playback_title(request.meta.as_ref(), request.episode.as_ref(), &request.stream),
|
||||||
|
"artwork": playback_artwork(request.meta.as_ref(), request.episode.as_ref()),
|
||||||
|
"preferredPlayer": request.preferred_player.unwrap_or_else(|| "mpv".to_string())
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn library_local_state_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<LibraryLocalStateRequest>(request_json).ok()?;
|
||||||
|
let id = request
|
||||||
|
.primary_id
|
||||||
|
.as_deref()
|
||||||
|
.or(request.fallback_id.as_deref())
|
||||||
|
.unwrap_or("");
|
||||||
|
let progress = request
|
||||||
|
.library
|
||||||
|
.get("progress")
|
||||||
|
.and_then(|value| value.get(id))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null);
|
||||||
|
let is_in_watchlist = request
|
||||||
|
.library
|
||||||
|
.get("watchlist")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.get("id").and_then(Value::as_str) == Some(id))
|
||||||
|
});
|
||||||
|
let watched_video_ids = request
|
||||||
|
.library
|
||||||
|
.get("watched")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.map(|watched| {
|
||||||
|
watched
|
||||||
|
.iter()
|
||||||
|
.filter(|(key, value)| key.starts_with(id) && value.as_bool().unwrap_or(false))
|
||||||
|
.map(|(key, _)| Value::String(key.clone()))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"progress": progress,
|
||||||
|
"isInWatchlist": is_in_watchlist,
|
||||||
|
"watchedVideoIds": watched_video_ids
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn preferences_schema_json() -> String {
|
||||||
|
json!({
|
||||||
|
"keys": [
|
||||||
|
"language",
|
||||||
|
"startPage",
|
||||||
|
"preferredPlayer",
|
||||||
|
"streamSourceSelectionMode",
|
||||||
|
"streamSourceRegexPattern",
|
||||||
|
"preferredAudioLanguage",
|
||||||
|
"secondaryAudioLanguage",
|
||||||
|
"preferredSubtitleLanguage",
|
||||||
|
"secondarySubtitleLanguage",
|
||||||
|
"subtitleSize",
|
||||||
|
"playbackSpeed",
|
||||||
|
"torrentSpeedPreset",
|
||||||
|
"torrentCachePreset",
|
||||||
|
"downloadSourceSelectionMode",
|
||||||
|
"downloadSubtitleLanguage"
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn apply_preference_update_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<PreferenceUpdateRequest>(request_json).ok()?;
|
||||||
|
let mut updated = request.existing;
|
||||||
|
let value = normalize_preference_value(&request.key, request.value);
|
||||||
|
updated.insert(request.key, value);
|
||||||
|
serde_json::to_string(&Value::Object(updated)).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn addon_collection_mutation_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<AddonCollectionMutationRequest>(request_json).ok()?;
|
||||||
|
let mut addons = request.existing;
|
||||||
|
if let Some(remove_key) = request.remove_key.as_deref() {
|
||||||
|
addons.retain(|addon| addon_key(addon) != remove_key);
|
||||||
|
}
|
||||||
|
for incoming in request.incoming {
|
||||||
|
let key = addon_key(&incoming);
|
||||||
|
if key.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(existing) = addons.iter_mut().find(|addon| addon_key(addon) == key) {
|
||||||
|
*existing = incoming;
|
||||||
|
} else {
|
||||||
|
addons.push(incoming);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::to_string(&json!({ "addons": addons })).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn detail_episode_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<DetailEpisodePlanRequest>(request_json).ok()?;
|
||||||
|
let mut seasons = request
|
||||||
|
.episodes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|episode| episode.get("season").and_then(Value::as_i64).or(Some(1)))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
seasons.sort_unstable();
|
||||||
|
seasons.dedup();
|
||||||
|
// Search for the target episode across ALL episodes before season filtering,
|
||||||
|
// so that a lastVideoId from a later season (e.g. S9 when default would be S1) is found.
|
||||||
|
let target_episode = request
|
||||||
|
.selected_episode_id
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|id| {
|
||||||
|
request
|
||||||
|
.episodes
|
||||||
|
.iter()
|
||||||
|
.find(|ep| ep.get("id").and_then(Value::as_str) == Some(id))
|
||||||
|
.cloned()
|
||||||
|
});
|
||||||
|
let selected_season = target_episode
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|ep| ep.get("season").and_then(Value::as_i64))
|
||||||
|
.or_else(|| request.selected_season.filter(|season| seasons.contains(season)))
|
||||||
|
.or_else(|| seasons.first().copied())
|
||||||
|
.unwrap_or(1);
|
||||||
|
let episodes = request
|
||||||
|
.episodes
|
||||||
|
.into_iter()
|
||||||
|
.filter(|episode| episode.get("season").and_then(Value::as_i64).unwrap_or(1) == selected_season)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let selected_episode = target_episode
|
||||||
|
.filter(|ep| ep.get("season").and_then(Value::as_i64).unwrap_or(1) == selected_season)
|
||||||
|
.or_else(|| episodes.first().cloned());
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"seasonNumbers": seasons,
|
||||||
|
"selectedSeason": selected_season,
|
||||||
|
"episodes": episodes,
|
||||||
|
"selectedEpisode": selected_episode,
|
||||||
|
"streamRequestId": selected_episode
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|episode| episode.get("id").and_then(Value::as_str))
|
||||||
|
.or(request.meta_id.as_deref())
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extra_json(request: &ResourceFetchPlanRequest) -> Option<String> {
|
||||||
|
let mut extra = request.extra.clone();
|
||||||
|
if let Some(genre) = request.genre.as_ref().filter(|value| !value.is_empty()) {
|
||||||
|
extra.insert("genre".to_string(), Value::String(genre.clone()));
|
||||||
|
}
|
||||||
|
if let Some(search) = request.query.as_ref().filter(|value| !value.is_empty()) {
|
||||||
|
extra.insert("search".to_string(), Value::String(search.clone()));
|
||||||
|
}
|
||||||
|
if let Some(skip) = request.skip.filter(|value| *value > 0) {
|
||||||
|
extra.insert("skip".to_string(), Value::Number(skip.into()));
|
||||||
|
}
|
||||||
|
(!extra.is_empty()).then(|| Value::Object(extra).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn addon_transport_url(addon: &Value) -> Option<&str> {
|
||||||
|
addon.get("transportUrl").and_then(Value::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn addon_manifest(addon: &Value) -> Value {
|
||||||
|
addon.get("manifest").cloned().unwrap_or_else(|| addon.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn addon_catalogs(addon: &Value) -> Vec<Value> {
|
||||||
|
addon_manifest(addon)
|
||||||
|
.get("catalogs")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn addon_supports(addon: &Value, resource: &str, content_type: &str, id: Option<&str>) -> bool {
|
||||||
|
let manifest = addon_manifest(addon);
|
||||||
|
supports_resource(&manifest.to_string(), resource, Some(content_type), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn addon_display_name(addon: &Value) -> String {
|
||||||
|
addon.get("name")
|
||||||
|
.or_else(|| addon.get("manifest").and_then(|manifest| manifest.get("name")))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("Unknown Addon")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn catalog_supports_search(catalog: &Value) -> bool {
|
||||||
|
catalog
|
||||||
|
.get("extra")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|extra| {
|
||||||
|
extra
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.get("name").and_then(Value::as_str) == Some("search"))
|
||||||
|
})
|
||||||
|
|| catalog
|
||||||
|
.get("extraSupported")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|extra| extra.iter().any(|item| item.as_str() == Some("search")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search_category_name(addon: &Value, catalog: &Value, content_type: &str) -> String {
|
||||||
|
let addon_name = addon_display_name(addon);
|
||||||
|
let catalog_name = catalog
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.unwrap_or(match content_type {
|
||||||
|
"movie" => "Movies",
|
||||||
|
"series" => "Series",
|
||||||
|
other => other,
|
||||||
|
});
|
||||||
|
format!("{addon_name} - {catalog_name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DiscoverCatalog {
|
||||||
|
key: String,
|
||||||
|
transport_url: String,
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_catalog_options(addons: &[Value], selected_type: &str) -> Vec<DiscoverCatalog> {
|
||||||
|
let mut options = Vec::new();
|
||||||
|
for addon in addons {
|
||||||
|
let Some(transport_url) = addon_transport_url(addon) else { continue };
|
||||||
|
for catalog in addon_catalogs(addon) {
|
||||||
|
let Some(content_type) = catalog.get("type").and_then(Value::as_str) else { continue };
|
||||||
|
let Some(id) = catalog.get("id").and_then(Value::as_str) else { continue };
|
||||||
|
if !selected_type.is_empty() && content_type != selected_type {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
options.push(DiscoverCatalog {
|
||||||
|
key: format!("{}:{}", transport_url, id),
|
||||||
|
transport_url: transport_url.to_string(),
|
||||||
|
content_type: content_type.to_string(),
|
||||||
|
id: id.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options
|
||||||
|
}
|
||||||
|
|
||||||
|
fn playback_title(meta: Option<&Value>, episode: Option<&Value>, stream: &Value) -> Value {
|
||||||
|
let content_title = meta
|
||||||
|
.and_then(|value| value.get("name"))
|
||||||
|
.or_else(|| stream.get("title"))
|
||||||
|
.or_else(|| stream.get("name"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("Fluxa");
|
||||||
|
let season = episode.and_then(|value| value.get("season")).and_then(Value::as_i64);
|
||||||
|
let episode_number = episode
|
||||||
|
.and_then(|value| value.get("episode").or_else(|| value.get("number")))
|
||||||
|
.and_then(Value::as_i64);
|
||||||
|
let episode_name = episode
|
||||||
|
.and_then(|value| value.get("name").or_else(|| value.get("title")))
|
||||||
|
.and_then(Value::as_str);
|
||||||
|
let episode_line = match (season, episode_number) {
|
||||||
|
(Some(season), Some(number)) => {
|
||||||
|
let prefix = format!("S{season}:E{number}");
|
||||||
|
Some(match episode_name.filter(|value| !value.trim().is_empty()) {
|
||||||
|
Some(name) => format!("{prefix} {}", name.trim()),
|
||||||
|
None => prefix,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
json!({ "contentTitle": content_title, "episodeLine": episode_line })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn playback_artwork(meta: Option<&Value>, episode: Option<&Value>) -> Value {
|
||||||
|
let background = meta
|
||||||
|
.and_then(|value| first_text(value, &["background", "backgroundUrl", "backdrop", "backdropUrl"]))
|
||||||
|
.or_else(|| episode.and_then(|value| value.get("thumbnail")).and_then(Value::as_str))
|
||||||
|
.or_else(|| meta.and_then(|value| value.get("poster")).and_then(Value::as_str));
|
||||||
|
let logo = meta.and_then(|value| first_text(value, &["logo", "logoUrl", "titleLogo", "titleLogoUrl"]));
|
||||||
|
json!({ "background": background, "logo": logo })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_text<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> {
|
||||||
|
keys.iter()
|
||||||
|
.find_map(|key| value.get(*key).and_then(Value::as_str).filter(|text| !text.trim().is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_preference_value(key: &str, value: Value) -> Value {
|
||||||
|
match key {
|
||||||
|
"preferredPlayer" => enum_string(value, &["mpv", "exoplayer", "external"], "mpv"),
|
||||||
|
"streamSourceSelectionMode" | "downloadSourceSelectionMode" => {
|
||||||
|
enum_string(value, &["manual", "first", "best", "regex"], "manual")
|
||||||
|
}
|
||||||
|
"downloadSubtitleLanguage" => enum_string(
|
||||||
|
value,
|
||||||
|
&["off", "preferred", "tr", "en", "ja", "es", "fr", "de"],
|
||||||
|
"preferred",
|
||||||
|
),
|
||||||
|
"torrentSpeedPreset" => enum_string(value, &["default", "fast", "ultra_fast"], "default"),
|
||||||
|
"torrentCachePreset" => enum_string(value, &["auto", "2gb", "5gb", "10gb", "unlimited"], "auto"),
|
||||||
|
"subtitleSize" => enum_string(value, &["50", "75", "100", "125", "150", "200"], "100"),
|
||||||
|
_ => value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enum_string(value: Value, allowed: &[&str], fallback: &str) -> Value {
|
||||||
|
let text = value.as_str().unwrap_or(fallback);
|
||||||
|
if allowed.contains(&text) {
|
||||||
|
Value::String(text.to_string())
|
||||||
|
} else {
|
||||||
|
Value::String(fallback.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn addon_key(addon: &Value) -> String {
|
||||||
|
addon.get("transportUrl")
|
||||||
|
.or_else(|| addon.get("id"))
|
||||||
|
.or_else(|| addon.get("manifest").and_then(|manifest| manifest.get("id")))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
227
src/player_flow.rs
Normal file
227
src/player_flow.rs
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
use crate::stream_policy;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct PlayerFlowState {
|
||||||
|
#[serde(default)]
|
||||||
|
current_video_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
current_streams: Vec<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
current_stream_index: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
current_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
zero_speed_ticks: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
is_buffering: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
is_video_rendered: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
player_error: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
preferred_binge_group: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(
|
||||||
|
rename_all = "camelCase",
|
||||||
|
rename_all_fields = "camelCase",
|
||||||
|
tag = "type"
|
||||||
|
)]
|
||||||
|
enum PlayerFlowAction {
|
||||||
|
#[serde(rename = "loadStreamsRequested")]
|
||||||
|
LoadStreamsRequested {
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
current_video_id: Option<String>,
|
||||||
|
initial_video_id: Option<String>,
|
||||||
|
initial_streams: Vec<Value>,
|
||||||
|
initial_stream_index: i32,
|
||||||
|
},
|
||||||
|
#[serde(rename = "streamsLoaded")]
|
||||||
|
StreamsLoaded {
|
||||||
|
streams: Vec<Value>,
|
||||||
|
current_video_id: Option<String>,
|
||||||
|
initial_stream_index: i32,
|
||||||
|
saved_url: Option<String>,
|
||||||
|
saved_title: Option<String>,
|
||||||
|
source_selection_mode: Option<String>,
|
||||||
|
regex_pattern: Option<String>,
|
||||||
|
preferred_binge_group: Option<String>,
|
||||||
|
},
|
||||||
|
#[serde(rename = "streamsFailed")]
|
||||||
|
StreamsFailed { error_code: Option<String> },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct PlayerFlowResult {
|
||||||
|
state: PlayerFlowState,
|
||||||
|
effects: Vec<PlayerFlowEffect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
|
#[serde(
|
||||||
|
rename_all = "camelCase",
|
||||||
|
rename_all_fields = "camelCase",
|
||||||
|
tag = "type"
|
||||||
|
)]
|
||||||
|
enum PlayerFlowEffect {
|
||||||
|
#[serde(rename = "loadStreams")]
|
||||||
|
LoadStreams {
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
use_initial_streams: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn player_flow_dispatch_json(state_json: &str, action_json: &str) -> Option<String> {
|
||||||
|
let mut state: PlayerFlowState = serde_json::from_str(state_json).unwrap_or_default();
|
||||||
|
let action: PlayerFlowAction = serde_json::from_str(action_json).ok()?;
|
||||||
|
let effects = dispatch(&mut state, action);
|
||||||
|
serde_json::to_string(&PlayerFlowResult { state, effects }).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch(state: &mut PlayerFlowState, action: PlayerFlowAction) -> Vec<PlayerFlowEffect> {
|
||||||
|
match action {
|
||||||
|
PlayerFlowAction::LoadStreamsRequested {
|
||||||
|
content_type,
|
||||||
|
id,
|
||||||
|
current_video_id,
|
||||||
|
initial_video_id,
|
||||||
|
initial_streams,
|
||||||
|
initial_stream_index,
|
||||||
|
} => {
|
||||||
|
state.current_video_id = current_video_id.clone();
|
||||||
|
state.current_streams.clear();
|
||||||
|
state.current_stream_index = initial_stream_index;
|
||||||
|
state.current_url = None;
|
||||||
|
state.zero_speed_ticks = 0;
|
||||||
|
state.is_buffering = true;
|
||||||
|
state.is_video_rendered = false;
|
||||||
|
state.player_error = None;
|
||||||
|
let use_initial_streams =
|
||||||
|
!initial_streams.is_empty() && current_video_id == initial_video_id;
|
||||||
|
vec![PlayerFlowEffect::LoadStreams {
|
||||||
|
content_type,
|
||||||
|
id,
|
||||||
|
use_initial_streams,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
PlayerFlowAction::StreamsLoaded {
|
||||||
|
streams,
|
||||||
|
current_video_id,
|
||||||
|
initial_stream_index,
|
||||||
|
saved_url,
|
||||||
|
saved_title,
|
||||||
|
source_selection_mode,
|
||||||
|
regex_pattern,
|
||||||
|
preferred_binge_group,
|
||||||
|
} => {
|
||||||
|
if streams.is_empty() {
|
||||||
|
state.current_streams.clear();
|
||||||
|
state.current_url = None;
|
||||||
|
state.is_buffering = false;
|
||||||
|
state.player_error = Some("no_source".to_string());
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected_index = stream_policy::select_stream_index_values(
|
||||||
|
&streams,
|
||||||
|
current_video_id.as_deref().unwrap_or_default(),
|
||||||
|
initial_stream_index,
|
||||||
|
saved_url.as_deref(),
|
||||||
|
saved_title.as_deref(),
|
||||||
|
source_selection_mode.as_deref().unwrap_or("manual"),
|
||||||
|
regex_pattern.as_deref(),
|
||||||
|
preferred_binge_group.as_deref(),
|
||||||
|
)
|
||||||
|
.clamp(0, streams.len().saturating_sub(1) as i32);
|
||||||
|
|
||||||
|
state.current_streams = streams;
|
||||||
|
state.current_stream_index = selected_index;
|
||||||
|
state.current_url = state
|
||||||
|
.current_streams
|
||||||
|
.get(selected_index as usize)
|
||||||
|
.and_then(playable_url);
|
||||||
|
state.is_buffering = state.current_url.is_none();
|
||||||
|
state.is_video_rendered = false;
|
||||||
|
state.player_error = None;
|
||||||
|
state.preferred_binge_group = None;
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
PlayerFlowAction::StreamsFailed { error_code } => {
|
||||||
|
state.current_url = None;
|
||||||
|
state.is_buffering = false;
|
||||||
|
state.player_error = Some(error_code.unwrap_or_else(|| "generic".to_string()));
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn playable_url(stream: &Value) -> Option<String> {
|
||||||
|
stream
|
||||||
|
.get("playableUrl")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.or_else(|| stream_policy::stream_playable_url(stream))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::player_flow_dispatch_json;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_request_returns_effect_and_resets_playback_state() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&player_flow_dispatch_json(
|
||||||
|
r#"{"currentUrl":"http://old","isVideoRendered":true}"#,
|
||||||
|
r#"{"type":"loadStreamsRequested","contentType":"series","id":"tt1:1:2","currentVideoId":"tt1:1:2","initialVideoId":"tt1:1:2","initialStreams":[{"playableUrl":"http://s"}],"initialStreamIndex":2}"#,
|
||||||
|
)
|
||||||
|
.expect("result"),
|
||||||
|
)
|
||||||
|
.expect("json");
|
||||||
|
|
||||||
|
assert_eq!(result["state"]["currentUrl"], Value::Null);
|
||||||
|
assert_eq!(result["state"]["isBuffering"], true);
|
||||||
|
assert_eq!(result["effects"][0]["type"], "loadStreams");
|
||||||
|
assert_eq!(result["effects"][0]["useInitialStreams"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loaded_streams_select_url_without_reordering_provider_results() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&player_flow_dispatch_json(
|
||||||
|
"{}",
|
||||||
|
r#"{"type":"streamsLoaded","streams":[{"title":"A","playableUrl":"http://a"},{"title":"B","playableUrl":"http://b"}],"currentVideoId":"tt1","initialStreamIndex":1,"sourceSelectionMode":"manual"}"#,
|
||||||
|
)
|
||||||
|
.expect("result"),
|
||||||
|
)
|
||||||
|
.expect("json");
|
||||||
|
|
||||||
|
assert_eq!(result["state"]["currentStreamIndex"], 1);
|
||||||
|
assert_eq!(result["state"]["currentUrl"], "http://b");
|
||||||
|
assert_eq!(result["state"]["currentStreams"][0]["title"], "A");
|
||||||
|
assert_eq!(result["state"]["currentStreams"][1]["title"], "B");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_streams_return_no_source_error_code() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&player_flow_dispatch_json(
|
||||||
|
"{}",
|
||||||
|
r#"{"type":"streamsLoaded","streams":[],"initialStreamIndex":0}"#,
|
||||||
|
)
|
||||||
|
.expect("result"),
|
||||||
|
)
|
||||||
|
.expect("json");
|
||||||
|
|
||||||
|
assert_eq!(result["state"]["playerError"], "no_source");
|
||||||
|
assert_eq!(result["state"]["isBuffering"], false);
|
||||||
|
}
|
||||||
|
}
|
||||||
1324
src/player_policy.rs
Normal file
1324
src/player_policy.rs
Normal file
File diff suppressed because it is too large
Load diff
95
src/player_scrobble.rs
Normal file
95
src/player_scrobble.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
const SCROBBLE_START_PROGRESS_PERCENT: f32 = 0.2;
|
||||||
|
const SCROBBLE_STOP_PROGRESS_PERCENT: f32 = 80.0;
|
||||||
|
const DURABLE_SCROBBLE_MIN_PROGRESS_PERCENT: f32 = 1.0;
|
||||||
|
const PERIODIC_PROGRESS_SAVE_MS: i64 = 30_000;
|
||||||
|
const DISPOSAL_PROGRESS_SAVE_MIN_MS: i64 = 5_000;
|
||||||
|
|
||||||
|
pub(crate) fn progress_percent(position_ms: i64, duration_ms: i64) -> f32 {
|
||||||
|
if duration_ms <= 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
((position_ms as f32 / duration_ms as f32) * 100.0).clamp(0.0, 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn should_send_start(
|
||||||
|
token: Option<&str>,
|
||||||
|
is_playing: bool,
|
||||||
|
has_scrobbled_start: bool,
|
||||||
|
progress: f32,
|
||||||
|
) -> bool {
|
||||||
|
has_token(token)
|
||||||
|
&& is_playing
|
||||||
|
&& !has_scrobbled_start
|
||||||
|
&& progress > SCROBBLE_START_PROGRESS_PERCENT
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn should_mark_stopped(has_scrobbled_stop: bool, progress: f32) -> bool {
|
||||||
|
!has_scrobbled_stop && progress >= SCROBBLE_STOP_PROGRESS_PERCENT
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn should_queue_pause(
|
||||||
|
token: Option<&str>,
|
||||||
|
was_play_when_ready: bool,
|
||||||
|
has_scrobbled_start: bool,
|
||||||
|
has_scrobbled_stop: bool,
|
||||||
|
) -> bool {
|
||||||
|
has_token(token) && was_play_when_ready && has_scrobbled_start && !has_scrobbled_stop
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn should_enqueue_durable(action: &str, token: Option<&str>, progress: f32) -> bool {
|
||||||
|
if !has_token(token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
(action != "pause" && action != "stop") || progress >= DURABLE_SCROBBLE_MIN_PROGRESS_PERCENT
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn should_save_periodic_progress(
|
||||||
|
is_playing: bool,
|
||||||
|
now_ms: i64,
|
||||||
|
last_saved_at_ms: i64,
|
||||||
|
) -> bool {
|
||||||
|
is_playing && now_ms - last_saved_at_ms > PERIODIC_PROGRESS_SAVE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn should_save_on_dispose(position_ms: i64) -> bool {
|
||||||
|
position_ms > DISPOSAL_PROGRESS_SAVE_MIN_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_token(token: Option<&str>) -> bool {
|
||||||
|
token.is_some_and(|value| !value.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_percent_is_clamped_and_zero_for_missing_duration() {
|
||||||
|
assert_eq!(progress_percent(1_000, 0), 0.0);
|
||||||
|
assert_eq!(progress_percent(5_000, 10_000), 50.0);
|
||||||
|
assert_eq!(progress_percent(20_000, 10_000), 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_requires_token_playing_and_initial_progress() {
|
||||||
|
assert!(!should_send_start(None, true, false, 1.0));
|
||||||
|
assert!(!should_send_start(Some("token"), false, false, 1.0));
|
||||||
|
assert!(!should_send_start(Some("token"), true, true, 1.0));
|
||||||
|
assert!(!should_send_start(Some("token"), true, false, 0.1));
|
||||||
|
assert!(should_send_start(Some("token"), true, false, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pause_stop_and_save_thresholds_match_platform_contract() {
|
||||||
|
assert!(!should_mark_stopped(false, 79.9));
|
||||||
|
assert!(should_mark_stopped(false, 80.0));
|
||||||
|
assert!(!should_mark_stopped(true, 90.0));
|
||||||
|
assert!(!should_enqueue_durable("pause", Some("token"), 0.5));
|
||||||
|
assert!(should_enqueue_durable("pause", Some("token"), 1.0));
|
||||||
|
assert!(should_enqueue_durable("start", Some("token"), 0.1));
|
||||||
|
assert!(!should_save_periodic_progress(true, 30_000, 0));
|
||||||
|
assert!(should_save_periodic_progress(true, 30_001, 0));
|
||||||
|
assert!(!should_save_on_dispose(5_000));
|
||||||
|
assert!(should_save_on_dispose(5_001));
|
||||||
|
}
|
||||||
|
}
|
||||||
469
src/profile_contract.rs
Normal file
469
src/profile_contract.rs
Normal file
|
|
@ -0,0 +1,469 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
const DEFAULT_ADDON_URL: &str = "https://v3-cinemeta.strem.io/manifest.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ActiveProfileRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
profiles: Vec<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
stored_active_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct TokenMergeRequest {
|
||||||
|
profile: Value,
|
||||||
|
auth_result: Value,
|
||||||
|
provider: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DefaultProfileRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
email: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
auth_key: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
language: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SettingsMigrationRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
raw: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
schema_version: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct AvatarDefaultRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
profile: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
catalog: Vec<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn active_profile_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<ActiveProfileRequest>(request_json).ok()?;
|
||||||
|
if request.profiles.is_empty() {
|
||||||
|
return serde_json::to_string(&json!({
|
||||||
|
"activeId": "guest",
|
||||||
|
"shouldCreateDefault": true,
|
||||||
|
"activeProfile": Value::Null
|
||||||
|
}))
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
let stored = request.stored_active_id.as_deref().unwrap_or("").trim();
|
||||||
|
let active = if stored.is_empty() || stored == "guest" {
|
||||||
|
request
|
||||||
|
.profiles
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(Value::Null)
|
||||||
|
} else {
|
||||||
|
request
|
||||||
|
.profiles
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.get("id").and_then(Value::as_str) == Some(stored))
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| request.profiles.first().cloned())
|
||||||
|
.unwrap_or(Value::Null)
|
||||||
|
};
|
||||||
|
let active_id = active
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("guest")
|
||||||
|
.to_string();
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"activeId": active_id,
|
||||||
|
"shouldCreateDefault": false,
|
||||||
|
"activeProfile": active
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn token_merge_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<TokenMergeRequest>(request_json).ok()?;
|
||||||
|
let mut profile = request.profile.clone();
|
||||||
|
let auth = &request.auth_result;
|
||||||
|
let provider = request.provider.as_str();
|
||||||
|
|
||||||
|
if profile.is_null() || !profile.is_object() {
|
||||||
|
profile = json!({});
|
||||||
|
}
|
||||||
|
|
||||||
|
let obj = profile.as_object_mut()?;
|
||||||
|
|
||||||
|
match provider {
|
||||||
|
"trakt" => {
|
||||||
|
let token = auth.get("accessToken").or_else(|| auth.get("access_token"));
|
||||||
|
let refresh = auth.get("refreshToken").or_else(|| auth.get("refresh_token"));
|
||||||
|
let expires_at = auth
|
||||||
|
.get("expiresAt")
|
||||||
|
.or_else(|| auth.get("expires_at"))
|
||||||
|
.or_else(|| auth.get("traktTokenExpiresAt"));
|
||||||
|
if let Some(t) = token {
|
||||||
|
obj.insert("traktAccessToken".to_string(), t.clone());
|
||||||
|
}
|
||||||
|
if let Some(r) = refresh {
|
||||||
|
obj.insert("traktRefreshToken".to_string(), r.clone());
|
||||||
|
}
|
||||||
|
if let Some(e) = expires_at {
|
||||||
|
obj.insert("traktTokenExpiresAt".to_string(), e.clone());
|
||||||
|
}
|
||||||
|
obj.insert("traktLastSyncAt".to_string(), Value::Null);
|
||||||
|
}
|
||||||
|
"mal" => {
|
||||||
|
let token = auth.get("accessToken").or_else(|| auth.get("access_token"));
|
||||||
|
let refresh = auth.get("refreshToken").or_else(|| auth.get("refresh_token"));
|
||||||
|
if let Some(t) = token {
|
||||||
|
obj.insert("malAccessToken".to_string(), t.clone());
|
||||||
|
}
|
||||||
|
if let Some(r) = refresh {
|
||||||
|
obj.insert("malRefreshToken".to_string(), r.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"simkl" => {
|
||||||
|
let token = auth.get("accessToken").or_else(|| auth.get("access_token"));
|
||||||
|
if let Some(t) = token {
|
||||||
|
obj.insert("simklAccessToken".to_string(), t.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"stremio" | "account" => {
|
||||||
|
if let Some(auth_key) = auth.get("authKey").or_else(|| auth.get("apiKey")) {
|
||||||
|
obj.insert("authKey".to_string(), auth_key.clone());
|
||||||
|
}
|
||||||
|
if let Some(id) = auth.get("id") {
|
||||||
|
obj.insert("id".to_string(), id.clone());
|
||||||
|
}
|
||||||
|
if let Some(email) = auth.get("email") {
|
||||||
|
obj.insert("email".to_string(), email.clone());
|
||||||
|
}
|
||||||
|
obj.insert("isGuest".to_string(), json!(false));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"mergedProfile": profile,
|
||||||
|
"provider": provider
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn profile_default_seed_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<DefaultProfileRequest>(request_json).ok()?;
|
||||||
|
let id = request
|
||||||
|
.id
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "guest".to_string());
|
||||||
|
let email = request.email.unwrap_or_default();
|
||||||
|
let auth_key = request.auth_key.unwrap_or_default();
|
||||||
|
let language = request
|
||||||
|
.language
|
||||||
|
.filter(|s| !s.trim().is_empty())
|
||||||
|
.unwrap_or_else(|| "en".to_string());
|
||||||
|
let is_guest = id == "guest" || auth_key.is_empty();
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"id": id,
|
||||||
|
"email": email,
|
||||||
|
"authKey": auth_key,
|
||||||
|
"isGuest": is_guest,
|
||||||
|
"language": language,
|
||||||
|
"localAddons": [DEFAULT_ADDON_URL],
|
||||||
|
"disabledLocalAddons": []
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn profile_settings_migration_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<SettingsMigrationRequest>(request_json).ok()?;
|
||||||
|
let mut profile = request.raw.clone();
|
||||||
|
let schema_version = request.schema_version.unwrap_or(0);
|
||||||
|
|
||||||
|
if profile.is_null() || !profile.is_object() {
|
||||||
|
return serde_json::to_string(&json!({
|
||||||
|
"migratedProfile": Value::Null,
|
||||||
|
"appliedMigrations": []
|
||||||
|
}))
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
let obj = profile.as_object_mut()?;
|
||||||
|
let mut applied = Vec::<String>::new();
|
||||||
|
|
||||||
|
// Migration: flatten nested externalAccounts into top-level fields (v1 -> v2)
|
||||||
|
if schema_version < 2 {
|
||||||
|
if let Some(ext) = obj.remove("externalAccounts") {
|
||||||
|
if let Some(ext_obj) = ext.as_object() {
|
||||||
|
for (k, v) in ext_obj {
|
||||||
|
obj.entry(k.clone()).or_insert(v.clone());
|
||||||
|
}
|
||||||
|
applied.push("flatten_external_accounts".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: flatten nested addonSettings into top-level localAddons/disabledLocalAddons
|
||||||
|
if schema_version < 2 {
|
||||||
|
if let Some(addon_settings) = obj.remove("addonSettings") {
|
||||||
|
if let Some(addon_obj) = addon_settings.as_object() {
|
||||||
|
if let Some(local) = addon_obj.get("localAddons") {
|
||||||
|
obj.entry("localAddons".to_string()).or_insert(local.clone());
|
||||||
|
}
|
||||||
|
if let Some(disabled) = addon_obj.get("disabledLocalAddons") {
|
||||||
|
obj.entry("disabledLocalAddons".to_string())
|
||||||
|
.or_insert(disabled.clone());
|
||||||
|
}
|
||||||
|
applied.push("flatten_addon_settings".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: flatten nested subtitleSettings
|
||||||
|
if schema_version < 2 {
|
||||||
|
if let Some(sub_settings) = obj.remove("subtitleSettings") {
|
||||||
|
if let Some(sub_obj) = sub_settings.as_object() {
|
||||||
|
let field_map = [
|
||||||
|
("size", "subtitleSize"),
|
||||||
|
("color", "subtitleColor"),
|
||||||
|
("backgroundColor", "subtitleBackgroundColor"),
|
||||||
|
("outlineColor", "subtitleOutlineColor"),
|
||||||
|
("textOpacity", "subtitleTextOpacity"),
|
||||||
|
("backgroundOpacity", "subtitleBackgroundOpacity"),
|
||||||
|
("outlineOpacity", "subtitleOutlineOpacity"),
|
||||||
|
("preferredLanguage", "preferredSubtitleLanguage"),
|
||||||
|
("secondaryLanguage", "secondarySubtitleLanguage"),
|
||||||
|
("shadow", "subtitleShadow"),
|
||||||
|
("autoEnable", "autoEnableSubtitles"),
|
||||||
|
];
|
||||||
|
for (src, dst) in field_map {
|
||||||
|
if let Some(v) = sub_obj.get(src) {
|
||||||
|
obj.entry(dst.to_string()).or_insert(v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applied.push("flatten_subtitle_settings".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: flatten nested playbackSettings
|
||||||
|
if schema_version < 2 {
|
||||||
|
if let Some(pb_settings) = obj.remove("playbackSettings") {
|
||||||
|
if let Some(pb_obj) = pb_settings.as_object() {
|
||||||
|
for (k, v) in pb_obj {
|
||||||
|
obj.entry(k.clone()).or_insert(v.clone());
|
||||||
|
}
|
||||||
|
applied.push("flatten_playback_settings".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: flatten nested torrentSettings
|
||||||
|
if schema_version < 2 {
|
||||||
|
if let Some(torr_settings) = obj.remove("torrentSettings") {
|
||||||
|
if let Some(torr_obj) = torr_settings.as_object() {
|
||||||
|
let field_map = [
|
||||||
|
("wifiOnly", "torrentWifiOnly"),
|
||||||
|
("maxConnections", "torrentMaxConnections"),
|
||||||
|
("speedPreset", "torrentSpeedPreset"),
|
||||||
|
("cachePreset", "torrentCachePreset"),
|
||||||
|
];
|
||||||
|
for (src, dst) in field_map {
|
||||||
|
if let Some(v) = torr_obj.get(src) {
|
||||||
|
obj.entry(dst.to_string()).or_insert(v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applied.push("flatten_torrent_settings".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: flatten nested appearanceSettings
|
||||||
|
if schema_version < 2 {
|
||||||
|
if let Some(app_settings) = obj.remove("appearanceSettings") {
|
||||||
|
if let Some(app_obj) = app_settings.as_object() {
|
||||||
|
for (k, v) in app_obj {
|
||||||
|
obj.entry(k.clone()).or_insert(v.clone());
|
||||||
|
}
|
||||||
|
applied.push("flatten_appearance_settings".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: flatten nested homeFeedSettings
|
||||||
|
if schema_version < 2 {
|
||||||
|
if let Some(feed_settings) = obj.remove("homeFeedSettings") {
|
||||||
|
if let Some(feed_obj) = feed_settings.as_object() {
|
||||||
|
for (k, v) in feed_obj {
|
||||||
|
if k == "libraryCollections"
|
||||||
|
&& v.as_array().is_some_and(|items| !items.is_empty())
|
||||||
|
&& obj
|
||||||
|
.get(k)
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_none_or(|items| items.is_empty())
|
||||||
|
{
|
||||||
|
obj.insert(k.clone(), v.clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
obj.entry(k.clone()).or_insert(v.clone());
|
||||||
|
}
|
||||||
|
applied.push("flatten_home_feed_settings".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure localAddons always has at least the default addon
|
||||||
|
{
|
||||||
|
let has_local_addons = obj
|
||||||
|
.get("localAddons")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|arr| !arr.is_empty());
|
||||||
|
if !has_local_addons {
|
||||||
|
obj.insert(
|
||||||
|
"localAddons".to_string(),
|
||||||
|
json!([DEFAULT_ADDON_URL]),
|
||||||
|
);
|
||||||
|
applied.push("ensure_default_addon".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"migratedProfile": profile,
|
||||||
|
"appliedMigrations": applied,
|
||||||
|
"schemaVersion": 2
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn profile_avatar_default_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<AvatarDefaultRequest>(request_json).ok()?;
|
||||||
|
let existing = request
|
||||||
|
.profile
|
||||||
|
.get("avatarUrl")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|s| !s.trim().is_empty());
|
||||||
|
if let Some(url) = existing {
|
||||||
|
return serde_json::to_string(&json!({
|
||||||
|
"avatarUrl": url,
|
||||||
|
"fromCatalog": false
|
||||||
|
}))
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
let first_catalog = request
|
||||||
|
.catalog
|
||||||
|
.first()
|
||||||
|
.and_then(|e| e.get("url").and_then(Value::as_str))
|
||||||
|
.map(ToString::to_string);
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"avatarUrl": first_catalog,
|
||||||
|
"fromCatalog": first_catalog.is_some()
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_profile_plan_returns_first_when_no_stored_id() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&active_profile_plan_json(
|
||||||
|
r#"{"profiles":[{"id":"p1"},{"id":"p2"}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result["activeId"], "p1");
|
||||||
|
assert_eq!(result["shouldCreateDefault"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_profile_plan_creates_default_when_profiles_empty() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&active_profile_plan_json(r#"{"profiles":[]}"#).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result["activeId"], "guest");
|
||||||
|
assert_eq!(result["shouldCreateDefault"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_profile_plan_selects_stored_id() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&active_profile_plan_json(
|
||||||
|
r#"{"profiles":[{"id":"p1"},{"id":"p2"}],"storedActiveId":"p2"}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result["activeId"], "p2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_merge_plan_merges_trakt_tokens_into_profile() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&token_merge_plan_json(
|
||||||
|
r#"{"profile":{"id":"p1","email":"u@example.com"},"authResult":{"accessToken":"tok","refreshToken":"ref","expiresAt":999},"provider":"trakt"}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result["mergedProfile"]["traktAccessToken"], "tok");
|
||||||
|
assert_eq!(result["mergedProfile"]["traktRefreshToken"], "ref");
|
||||||
|
assert_eq!(result["mergedProfile"]["traktTokenExpiresAt"], 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_migration_flattens_nested_external_accounts() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&profile_settings_migration_plan_json(
|
||||||
|
r#"{"raw":{"id":"p1","externalAccounts":{"traktAccessToken":"tok"}},"schemaVersion":1}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result["migratedProfile"]["traktAccessToken"],
|
||||||
|
"tok"
|
||||||
|
);
|
||||||
|
assert!(result["appliedMigrations"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|m| m == "flatten_external_accounts"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_migration_keeps_nested_library_collections_when_top_level_is_empty() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&profile_settings_migration_plan_json(
|
||||||
|
r#"{"raw":{"id":"p1","libraryCollections":[],"homeFeedSettings":{"libraryCollections":[{"id":"c1","title":"Collection"}]}},"schemaVersion":1}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result["migratedProfile"]["libraryCollections"][0]["id"], "c1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_seed_produces_guest_profile_with_default_addon() {
|
||||||
|
let result: Value =
|
||||||
|
serde_json::from_str(&profile_default_seed_json("{}").unwrap()).unwrap();
|
||||||
|
assert_eq!(result["id"], "guest");
|
||||||
|
assert_eq!(result["isGuest"], true);
|
||||||
|
let addons = result["localAddons"].as_array().unwrap();
|
||||||
|
assert!(!addons.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
398
src/profile_prefs.rs
Normal file
398
src/profile_prefs.rs
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub(crate) fn safe_player_buffer_cache_mb(value: Option<i32>) -> i32 {
|
||||||
|
value.unwrap_or(100).clamp(100, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn safe_dolby_vision_fallback_mode(
|
||||||
|
mode: Option<&str>,
|
||||||
|
legacy_dv7_fallback: Option<bool>,
|
||||||
|
legacy_dv7_to_dv8_fallback: Option<bool>,
|
||||||
|
) -> &'static str {
|
||||||
|
match mode {
|
||||||
|
Some("auto") => "auto",
|
||||||
|
Some("convert_dv81") => "convert_dv81",
|
||||||
|
Some("hdr10") => "hdr10",
|
||||||
|
Some("dv8") => "dv8",
|
||||||
|
Some("off") => "off",
|
||||||
|
_ if legacy_dv7_to_dv8_fallback == Some(true) => "dv8",
|
||||||
|
_ if legacy_dv7_fallback != Some(false) => "hdr10",
|
||||||
|
_ => "off",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn safe_stream_source_selection_mode(mode: Option<&str>) -> &'static str {
|
||||||
|
match mode {
|
||||||
|
Some("first") => "first",
|
||||||
|
Some("regex") => "regex",
|
||||||
|
_ => "manual",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ProfileSafePrefs {
|
||||||
|
language: String,
|
||||||
|
subtitle_size_percent: f32,
|
||||||
|
subtitle_size: f32,
|
||||||
|
subtitle_color: i64,
|
||||||
|
subtitle_background_color: i64,
|
||||||
|
subtitle_outline_color: i64,
|
||||||
|
subtitle_text_opacity: f32,
|
||||||
|
subtitle_background_opacity: f32,
|
||||||
|
subtitle_outline_opacity: f32,
|
||||||
|
preferred_subtitle_language: String,
|
||||||
|
preferred_audio_language: String,
|
||||||
|
secondary_subtitle_language: String,
|
||||||
|
secondary_audio_language: String,
|
||||||
|
stable_volume: bool,
|
||||||
|
ambient_light: bool,
|
||||||
|
force_software_audio: bool,
|
||||||
|
preferred_player: String,
|
||||||
|
card_layout: String,
|
||||||
|
continue_watching_layout: String,
|
||||||
|
continue_watching_artwork: String,
|
||||||
|
continue_watching_enabled: bool,
|
||||||
|
resolved_continue_watching_layout: String,
|
||||||
|
subtitle_shadow: bool,
|
||||||
|
auto_enable_subtitles: bool,
|
||||||
|
auto_skip_intro: bool,
|
||||||
|
auto_play_next_episode: bool,
|
||||||
|
next_episode_threshold_percent: f32,
|
||||||
|
watched_threshold_percent: f32,
|
||||||
|
seek_forward_seconds: i64,
|
||||||
|
seek_backward_seconds: i64,
|
||||||
|
player_buffer_cache_mb: i32,
|
||||||
|
player_forward_buffer_seconds: i64,
|
||||||
|
player_back_buffer_seconds: i64,
|
||||||
|
timezone_conversion_enabled: bool,
|
||||||
|
torrent_wifi_only: bool,
|
||||||
|
torrent_max_connections: i64,
|
||||||
|
torrent_speed_preset: String,
|
||||||
|
torrent_cache_preset: String,
|
||||||
|
app_theme: String,
|
||||||
|
accent_color_argb: i64,
|
||||||
|
card_corner_preset: String,
|
||||||
|
interface_density: String,
|
||||||
|
amoled_mode: bool,
|
||||||
|
poster_width_preset: String,
|
||||||
|
poster_landscape_mode: bool,
|
||||||
|
poster_hide_titles: bool,
|
||||||
|
detail_episode_view_mode: String,
|
||||||
|
animations_enabled: bool,
|
||||||
|
reduce_motion: bool,
|
||||||
|
start_page: String,
|
||||||
|
notifications_enabled: bool,
|
||||||
|
alert_new_episodes: bool,
|
||||||
|
automatic_updates: bool,
|
||||||
|
background_playback: bool,
|
||||||
|
picture_in_picture: bool,
|
||||||
|
playback_speed: f32,
|
||||||
|
hold_to_speed_enabled: bool,
|
||||||
|
hold_speed: f32,
|
||||||
|
dolby_vision_fallback_mode: String,
|
||||||
|
dv7_to_dv8_fallback: bool,
|
||||||
|
dv7_fallback: bool,
|
||||||
|
tunneled_playback: bool,
|
||||||
|
use_intro_db: bool,
|
||||||
|
use_ani_skip: bool,
|
||||||
|
default_quality: String,
|
||||||
|
mobile_data_usage: String,
|
||||||
|
hdr_playback: bool,
|
||||||
|
resume_playback: bool,
|
||||||
|
autoplay_mode: String,
|
||||||
|
stream_source_selection_mode: String,
|
||||||
|
stream_source_regex_pattern: String,
|
||||||
|
try_binge_group: bool,
|
||||||
|
show_hero_section: bool,
|
||||||
|
trakt_token_expires_at: i64,
|
||||||
|
trakt_last_sync_at: i64,
|
||||||
|
trakt_last_synced_items: i64,
|
||||||
|
trakt_last_continue_watching_count: i64,
|
||||||
|
trakt_last_watchlist_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn profile_safe_prefs_json(profile_json: &str) -> Option<String> {
|
||||||
|
let profile = serde_json::from_str::<Value>(profile_json).ok()?;
|
||||||
|
serde_json::to_string(&profile_safe_prefs(&profile)).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profile_safe_prefs(profile: &Value) -> ProfileSafePrefs {
|
||||||
|
let subtitle_size_percent =
|
||||||
|
safe_subtitle_size_percent(number(profile, "subtitleSize").unwrap_or(100.0) as f32);
|
||||||
|
let card_layout = safe_card_layout(text(profile, "cardLayout"));
|
||||||
|
let continue_watching_layout =
|
||||||
|
safe_continue_watching_layout(text(profile, "continueWatchingLayout"));
|
||||||
|
let dolby_mode = safe_dolby_vision_fallback_mode(
|
||||||
|
text(profile, "dolbyVisionFallbackMode"),
|
||||||
|
bool_value(profile, "dv7Fallback"),
|
||||||
|
bool_value(profile, "dv7ToDv8Fallback"),
|
||||||
|
)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
ProfileSafePrefs {
|
||||||
|
language: text(profile, "language").unwrap_or("en").to_string(),
|
||||||
|
subtitle_size_percent,
|
||||||
|
subtitle_size: 20.0 * (subtitle_size_percent / 100.0),
|
||||||
|
subtitle_color: int(profile, "subtitleColor").unwrap_or(0xFFFF_FFFFu32 as i32 as i64),
|
||||||
|
subtitle_background_color: int(profile, "subtitleBackgroundColor")
|
||||||
|
.unwrap_or(0x8000_0000u32 as i32 as i64),
|
||||||
|
subtitle_outline_color: int(profile, "subtitleOutlineColor")
|
||||||
|
.unwrap_or(0xFF00_0000u32 as i32 as i64),
|
||||||
|
subtitle_text_opacity: number(profile, "subtitleTextOpacity").unwrap_or(1.0) as f32,
|
||||||
|
subtitle_background_opacity: number(profile, "subtitleBackgroundOpacity").unwrap_or(0.5)
|
||||||
|
as f32,
|
||||||
|
subtitle_outline_opacity: number(profile, "subtitleOutlineOpacity").unwrap_or(1.0) as f32,
|
||||||
|
preferred_subtitle_language: text(profile, "preferredSubtitleLanguage")
|
||||||
|
.unwrap_or("none")
|
||||||
|
.to_string(),
|
||||||
|
preferred_audio_language: text(profile, "preferredAudioLanguage")
|
||||||
|
.unwrap_or("none")
|
||||||
|
.to_string(),
|
||||||
|
secondary_subtitle_language: text(profile, "secondarySubtitleLanguage")
|
||||||
|
.unwrap_or("none")
|
||||||
|
.to_string(),
|
||||||
|
secondary_audio_language: text(profile, "secondaryAudioLanguage")
|
||||||
|
.unwrap_or("none")
|
||||||
|
.to_string(),
|
||||||
|
stable_volume: bool_value(profile, "stableVolume").unwrap_or(false),
|
||||||
|
ambient_light: bool_value(profile, "ambientLight").unwrap_or(true),
|
||||||
|
force_software_audio: bool_value(profile, "forceSoftwareAudio").unwrap_or(false),
|
||||||
|
preferred_player: safe_preferred_player(text(profile, "preferredPlayer")).to_string(),
|
||||||
|
card_layout: card_layout.clone(),
|
||||||
|
continue_watching_layout: continue_watching_layout.clone(),
|
||||||
|
continue_watching_artwork: text(profile, "continueWatchingArtwork")
|
||||||
|
.unwrap_or("episode")
|
||||||
|
.to_string(),
|
||||||
|
continue_watching_enabled: bool_value(profile, "continueWatchingEnabled").unwrap_or(true),
|
||||||
|
resolved_continue_watching_layout: if continue_watching_layout == "inherit" {
|
||||||
|
card_layout
|
||||||
|
} else {
|
||||||
|
continue_watching_layout
|
||||||
|
},
|
||||||
|
subtitle_shadow: bool_value(profile, "subtitleShadow").unwrap_or(true),
|
||||||
|
auto_enable_subtitles: bool_value(profile, "autoEnableSubtitles").unwrap_or(true),
|
||||||
|
auto_skip_intro: bool_value(profile, "autoSkipIntro").unwrap_or(false),
|
||||||
|
auto_play_next_episode: bool_value(profile, "autoPlayNextEpisode").unwrap_or(true),
|
||||||
|
next_episode_threshold_percent: number(profile, "nextEpisodeThresholdPercent")
|
||||||
|
.unwrap_or(90.0)
|
||||||
|
.clamp(50.0, 99.0) as f32,
|
||||||
|
watched_threshold_percent: number(profile, "watchedThresholdPercent")
|
||||||
|
.unwrap_or(80.0)
|
||||||
|
.clamp(50.0, 99.0) as f32,
|
||||||
|
seek_forward_seconds: int(profile, "seekForwardSeconds").unwrap_or(10),
|
||||||
|
seek_backward_seconds: int(profile, "seekBackwardSeconds").unwrap_or(10),
|
||||||
|
player_buffer_cache_mb: safe_player_buffer_cache_mb(
|
||||||
|
int(profile, "playerBufferCacheMb").map(|v| v as i32),
|
||||||
|
),
|
||||||
|
player_forward_buffer_seconds: int(profile, "playerForwardBufferSeconds")
|
||||||
|
.unwrap_or(120)
|
||||||
|
.clamp(30, 600),
|
||||||
|
player_back_buffer_seconds: int(profile, "playerBackBufferSeconds")
|
||||||
|
.unwrap_or(30)
|
||||||
|
.clamp(0, 300),
|
||||||
|
timezone_conversion_enabled: true,
|
||||||
|
torrent_wifi_only: bool_value(profile, "torrentWifiOnly").unwrap_or(false),
|
||||||
|
torrent_max_connections: int(profile, "torrentMaxConnections").unwrap_or(60),
|
||||||
|
torrent_speed_preset: text(profile, "torrentSpeedPreset")
|
||||||
|
.unwrap_or("default")
|
||||||
|
.to_string(),
|
||||||
|
torrent_cache_preset: text(profile, "torrentCachePreset")
|
||||||
|
.unwrap_or("auto")
|
||||||
|
.to_string(),
|
||||||
|
app_theme: text(profile, "appTheme").unwrap_or("dark").to_string(),
|
||||||
|
accent_color_argb: int(profile, "accentColorArgb").unwrap_or(0xFFFF_FFFFu32 as i32 as i64),
|
||||||
|
card_corner_preset: text(profile, "cardCornerPreset")
|
||||||
|
.unwrap_or("medium")
|
||||||
|
.to_string(),
|
||||||
|
interface_density: text(profile, "interfaceDensity")
|
||||||
|
.unwrap_or("medium")
|
||||||
|
.to_string(),
|
||||||
|
amoled_mode: bool_value(profile, "amoledMode").unwrap_or(false),
|
||||||
|
poster_width_preset: text(profile, "posterWidthPreset")
|
||||||
|
.unwrap_or("medium")
|
||||||
|
.to_string(),
|
||||||
|
poster_landscape_mode: bool_value(profile, "posterLandscapeMode").unwrap_or(false),
|
||||||
|
poster_hide_titles: bool_value(profile, "posterHideTitles").unwrap_or(false),
|
||||||
|
detail_episode_view_mode: safe_detail_episode_view_mode(text(
|
||||||
|
profile,
|
||||||
|
"detailEpisodeViewMode",
|
||||||
|
))
|
||||||
|
.to_string(),
|
||||||
|
animations_enabled: bool_value(profile, "animationsEnabled").unwrap_or(true),
|
||||||
|
reduce_motion: bool_value(profile, "reduceMotion").unwrap_or(false),
|
||||||
|
start_page: text(profile, "startPage").unwrap_or("home").to_string(),
|
||||||
|
notifications_enabled: bool_value(profile, "notificationsEnabled").unwrap_or(true),
|
||||||
|
alert_new_episodes: bool_value(profile, "alertNewEpisodes").unwrap_or(true),
|
||||||
|
automatic_updates: bool_value(profile, "automaticUpdates").unwrap_or(true),
|
||||||
|
background_playback: bool_value(profile, "backgroundPlayback").unwrap_or(false),
|
||||||
|
picture_in_picture: bool_value(profile, "pictureInPicture").unwrap_or(true),
|
||||||
|
playback_speed: number(profile, "playbackSpeed").unwrap_or(1.0) as f32,
|
||||||
|
hold_to_speed_enabled: bool_value(profile, "holdToSpeedEnabled").unwrap_or(true),
|
||||||
|
hold_speed: number(profile, "holdSpeed").unwrap_or(2.0) as f32,
|
||||||
|
dv7_to_dv8_fallback: dolby_mode == "dv8",
|
||||||
|
dv7_fallback: dolby_mode == "hdr10",
|
||||||
|
dolby_vision_fallback_mode: dolby_mode,
|
||||||
|
tunneled_playback: bool_value(profile, "tunneledPlayback").unwrap_or(false),
|
||||||
|
use_intro_db: bool_value(profile, "useIntroDb").unwrap_or(true),
|
||||||
|
use_ani_skip: bool_value(profile, "useAniSkip").unwrap_or(true),
|
||||||
|
default_quality: text(profile, "defaultQuality")
|
||||||
|
.unwrap_or("1080p")
|
||||||
|
.to_string(),
|
||||||
|
mobile_data_usage: text(profile, "mobileDataUsage")
|
||||||
|
.unwrap_or("medium")
|
||||||
|
.to_string(),
|
||||||
|
hdr_playback: bool_value(profile, "hdrPlayback").unwrap_or(true),
|
||||||
|
resume_playback: bool_value(profile, "resumePlayback").unwrap_or(true),
|
||||||
|
autoplay_mode: text(profile, "autoplayMode")
|
||||||
|
.unwrap_or("next_episode")
|
||||||
|
.to_string(),
|
||||||
|
stream_source_selection_mode: safe_stream_source_selection_mode(text(
|
||||||
|
profile,
|
||||||
|
"streamSourceSelectionMode",
|
||||||
|
))
|
||||||
|
.to_string(),
|
||||||
|
stream_source_regex_pattern: text(profile, "streamSourceRegexPattern")
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
try_binge_group: bool_value(profile, "tryBingeGroup").unwrap_or(false),
|
||||||
|
show_hero_section: bool_value(profile, "showHeroSection").unwrap_or(true),
|
||||||
|
trakt_token_expires_at: int(profile, "traktTokenExpiresAt").unwrap_or(0),
|
||||||
|
trakt_last_sync_at: int(profile, "traktLastSyncAt").unwrap_or(0),
|
||||||
|
trakt_last_synced_items: int(profile, "traktLastSyncedItems").unwrap_or(0),
|
||||||
|
trakt_last_continue_watching_count: int(profile, "traktLastContinueWatchingCount")
|
||||||
|
.unwrap_or(0),
|
||||||
|
trakt_last_watchlist_count: int(profile, "traktLastWatchlistCount").unwrap_or(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_subtitle_size_percent(value: f32) -> f32 {
|
||||||
|
if value <= 40.0 {
|
||||||
|
((value / 20.0) * 100.0).clamp(50.0, 200.0)
|
||||||
|
} else {
|
||||||
|
value.clamp(50.0, 200.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_preferred_player(value: Option<&str>) -> &'static str {
|
||||||
|
match value {
|
||||||
|
Some("mpv") => "mpv",
|
||||||
|
_ => "exoplayer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_card_layout(value: Option<&str>) -> String {
|
||||||
|
match value {
|
||||||
|
Some("episode") => "horizontal".to_string(),
|
||||||
|
None => "vertical".to_string(),
|
||||||
|
Some(value) => value.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_continue_watching_layout(value: Option<&str>) -> String {
|
||||||
|
match value {
|
||||||
|
Some("episode") => "horizontal".to_string(),
|
||||||
|
None => "horizontal".to_string(),
|
||||||
|
Some(value) => value.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_detail_episode_view_mode(value: Option<&str>) -> &'static str {
|
||||||
|
match value {
|
||||||
|
Some("legacy") => "legacy",
|
||||||
|
_ => "modern",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
|
||||||
|
value
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bool_value(value: &Value, key: &str) -> Option<bool> {
|
||||||
|
value.get(key).and_then(Value::as_bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn int(value: &Value, key: &str) -> Option<i64> {
|
||||||
|
value.get(key).and_then(Value::as_i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number(value: &Value, key: &str) -> Option<f64> {
|
||||||
|
value.get(key).and_then(Value::as_f64)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_buffer_cache_is_clamped() {
|
||||||
|
assert_eq!(safe_player_buffer_cache_mb(None), 100);
|
||||||
|
assert_eq!(safe_player_buffer_cache_mb(Some(50)), 100);
|
||||||
|
assert_eq!(safe_player_buffer_cache_mb(Some(500)), 500);
|
||||||
|
assert_eq!(safe_player_buffer_cache_mb(Some(5000)), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dolby_vision_mode_keeps_explicit_and_migrates_legacy_flags() {
|
||||||
|
assert_eq!(
|
||||||
|
safe_dolby_vision_fallback_mode(Some("auto"), None, None),
|
||||||
|
"auto"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
safe_dolby_vision_fallback_mode(None, Some(false), Some(true)),
|
||||||
|
"dv8",
|
||||||
|
);
|
||||||
|
assert_eq!(safe_dolby_vision_fallback_mode(None, None, None), "hdr10");
|
||||||
|
assert_eq!(
|
||||||
|
safe_dolby_vision_fallback_mode(None, Some(false), Some(false)),
|
||||||
|
"off",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_source_mode_defaults_to_manual() {
|
||||||
|
assert_eq!(safe_stream_source_selection_mode(Some("first")), "first");
|
||||||
|
assert_eq!(safe_stream_source_selection_mode(Some("regex")), "regex");
|
||||||
|
assert_eq!(safe_stream_source_selection_mode(Some("manual")), "manual");
|
||||||
|
assert_eq!(safe_stream_source_selection_mode(None), "manual");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_safe_prefs_match_kotlin_defaults_and_migrations() {
|
||||||
|
let json = profile_safe_prefs_json(
|
||||||
|
r#"{
|
||||||
|
"language":null,
|
||||||
|
"subtitleSize":20,
|
||||||
|
"preferredPlayer":"internal",
|
||||||
|
"cardLayout":"episode",
|
||||||
|
"continueWatchingLayout":"inherit",
|
||||||
|
"playerBufferCacheMb":50,
|
||||||
|
"playerForwardBufferSeconds":999,
|
||||||
|
"playerBackBufferSeconds":-1,
|
||||||
|
"detailEpisodeViewMode":"unknown",
|
||||||
|
"dolbyVisionFallbackMode":null,
|
||||||
|
"dv7Fallback":false,
|
||||||
|
"dv7ToDv8Fallback":true,
|
||||||
|
"streamSourceSelectionMode":"invalid"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.expect("profile safe prefs");
|
||||||
|
let value: Value = serde_json::from_str(&json).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(value["language"], "en");
|
||||||
|
assert_eq!(value["subtitleSizePercent"], 100.0);
|
||||||
|
assert_eq!(value["preferredPlayer"], "exoplayer");
|
||||||
|
assert_eq!(value["cardLayout"], "horizontal");
|
||||||
|
assert_eq!(value["resolvedContinueWatchingLayout"], "horizontal");
|
||||||
|
assert_eq!(value["playerBufferCacheMb"], 100);
|
||||||
|
assert_eq!(value["playerForwardBufferSeconds"], 600);
|
||||||
|
assert_eq!(value["playerBackBufferSeconds"], 0);
|
||||||
|
assert_eq!(value["detailEpisodeViewMode"], "modern");
|
||||||
|
assert_eq!(value["dolbyVisionFallbackMode"], "dv8");
|
||||||
|
assert_eq!(value["streamSourceSelectionMode"], "manual");
|
||||||
|
}
|
||||||
|
}
|
||||||
276
src/repository_flow.rs
Normal file
276
src/repository_flow.rs
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
use crate::addon_protocol::build_resource_url;
|
||||||
|
use crate::content_identity::parse_extra_args_json;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Map, Value};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct MetaDetailPlanRequest {
|
||||||
|
use_configured_addons: bool,
|
||||||
|
auth_key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
local_addons: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ManifestFetchDecisionRequest {
|
||||||
|
force_refresh: bool,
|
||||||
|
memory_hit: bool,
|
||||||
|
persistent_hit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct AddonResourceRequestPlan {
|
||||||
|
transport_url: String,
|
||||||
|
resource: String,
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
extra_args: Map<String, Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
extra_raw: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn repository_meta_detail_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request: MetaDetailPlanRequest = serde_json::from_str(request_json).ok()?;
|
||||||
|
let has_configured_source = !request.auth_key.trim().is_empty()
|
||||||
|
|| request
|
||||||
|
.local_addons
|
||||||
|
.iter()
|
||||||
|
.any(|addon| !addon.trim().is_empty());
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"preferAddonMetaDetail": request.use_configured_addons && has_configured_source,
|
||||||
|
"fallbackToStremioMetaDetail": true
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn repository_season_videos_json(meta_detail_json: &str, season_number: i32) -> String {
|
||||||
|
let videos = serde_json::from_str::<Value>(meta_detail_json)
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.get("videos").cloned())
|
||||||
|
.and_then(|value| value.as_array().cloned())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|video| {
|
||||||
|
video
|
||||||
|
.get("season")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.map(|season| season == season_number as i64)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Value::Array(videos).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn manifest_fetch_decision_json(request_json: &str) -> Option<String> {
|
||||||
|
let request: ManifestFetchDecisionRequest = serde_json::from_str(request_json).ok()?;
|
||||||
|
let phase = if request.force_refresh {
|
||||||
|
"fetch"
|
||||||
|
} else if request.memory_hit {
|
||||||
|
"memory"
|
||||||
|
} else if request.persistent_hit {
|
||||||
|
"persistent"
|
||||||
|
} else {
|
||||||
|
"fetch"
|
||||||
|
};
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"phase": phase,
|
||||||
|
"allowStaleFallback": true
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn addon_resource_request_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request: AddonResourceRequestPlan = serde_json::from_str(request_json).ok()?;
|
||||||
|
let mut urls = Vec::new();
|
||||||
|
if request.resource == "subtitles" || request.resource == "subtitle" {
|
||||||
|
urls.push(build_resource_url(
|
||||||
|
&request.transport_url,
|
||||||
|
"subtitles",
|
||||||
|
&request.content_type,
|
||||||
|
&request.id,
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
if !request.extra_raw.trim().is_empty() {
|
||||||
|
urls.push(build_resource_url(
|
||||||
|
&request.transport_url,
|
||||||
|
"subtitles",
|
||||||
|
&request.content_type,
|
||||||
|
&request.id,
|
||||||
|
parse_extra_args_json(&request.extra_raw).as_deref(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
urls.push(build_resource_url(
|
||||||
|
&request.transport_url,
|
||||||
|
&request.resource,
|
||||||
|
&request.content_type,
|
||||||
|
&request.id,
|
||||||
|
Some(&Value::Object(request.extra_args).to_string()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
urls.dedup();
|
||||||
|
serde_json::to_string(&json!({ "urls": urls })).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn addon_streams_with_provider_json(streams_json: &str, addon_name: &str) -> String {
|
||||||
|
let streams = serde_json::from_str::<Vec<Value>>(streams_json)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|stream| normalize_stream(stream, addon_name))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
Value::Array(streams).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_stream(mut stream: Value, addon_name: &str) -> Value {
|
||||||
|
let Some(stream_object) = stream.as_object_mut() else {
|
||||||
|
return stream;
|
||||||
|
};
|
||||||
|
if !addon_name.trim().is_empty() {
|
||||||
|
stream_object.insert(
|
||||||
|
"addonName".to_string(),
|
||||||
|
Value::String(addon_name.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let Some(behavior_hints) = stream_object
|
||||||
|
.get("behaviorHints")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.cloned()
|
||||||
|
else {
|
||||||
|
return stream;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut headers = Map::new();
|
||||||
|
collect_headers(behavior_hints.get("requestHeaders"), &mut headers);
|
||||||
|
let proxy_request = behavior_hints
|
||||||
|
.get("proxyHeaders")
|
||||||
|
.and_then(|proxy| proxy.get("request"));
|
||||||
|
collect_headers(proxy_request, &mut headers);
|
||||||
|
|
||||||
|
let mut final_hints = behavior_hints;
|
||||||
|
if !headers.is_empty() {
|
||||||
|
final_hints.insert("requestHeaders".to_string(), Value::Object(headers));
|
||||||
|
}
|
||||||
|
fill_from_hint(stream_object, &final_hints, "videoHash");
|
||||||
|
fill_from_hint(stream_object, &final_hints, "videoSize");
|
||||||
|
fill_from_hint(stream_object, &final_hints, "filename");
|
||||||
|
stream_object.insert("behaviorHints".to_string(), Value::Object(final_hints));
|
||||||
|
stream
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_headers(value: Option<&Value>, headers: &mut Map<String, Value>) {
|
||||||
|
let Some(map) = value.and_then(Value::as_object) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for (key, value) in map {
|
||||||
|
if key.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let text = value
|
||||||
|
.as_str()
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| value.to_string());
|
||||||
|
if !text.is_empty() {
|
||||||
|
headers.insert(key.clone(), Value::String(text));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_from_hint(stream_object: &mut Map<String, Value>, hints: &Map<String, Value>, key: &str) {
|
||||||
|
if stream_object
|
||||||
|
.get(key)
|
||||||
|
.filter(|value| !value.is_null())
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(value) = hints.get(key) {
|
||||||
|
stream_object.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_meta_detail_plan_prefers_addons_only_when_configured_source_exists() {
|
||||||
|
let plan = repository_meta_detail_plan_json(
|
||||||
|
r#"{"useConfiguredAddons":true,"authKey":"","localAddons":[]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(plan.contains(r#""preferAddonMetaDetail":false"#));
|
||||||
|
|
||||||
|
let plan = repository_meta_detail_plan_json(
|
||||||
|
r#"{"useConfiguredAddons":true,"authKey":"","localAddons":["https://addon/manifest.json"]}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(plan.contains(r#""preferAddonMetaDetail":true"#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_season_videos_filters_by_season_without_reordering() {
|
||||||
|
let videos = repository_season_videos_json(
|
||||||
|
r#"{"videos":[{"id":"s2e1","season":2},{"id":"s1e1","season":1},{"id":"s2e2","season":2}]}"#,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
videos,
|
||||||
|
r#"[{"id":"s2e1","season":2},{"id":"s2e2","season":2}]"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manifest_fetch_decision_uses_cache_before_network_unless_forced() {
|
||||||
|
assert!(manifest_fetch_decision_json(
|
||||||
|
r#"{"forceRefresh":false,"memoryHit":true,"persistentHit":true}"#
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.contains(r#""phase":"memory""#));
|
||||||
|
assert!(manifest_fetch_decision_json(
|
||||||
|
r#"{"forceRefresh":true,"memoryHit":true,"persistentHit":true}"#
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.contains(r#""phase":"fetch""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addon_resource_plan_builds_subtitle_and_catalog_urls() {
|
||||||
|
let subtitles = addon_resource_request_plan_json(
|
||||||
|
r#"{"transportUrl":"https://addon.example/manifest.json","resource":"subtitles","contentType":"movie","id":"tt1","extraRaw":"videoHash=abc&filename=File Name.mkv"}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(subtitles.contains("https://addon.example/subtitles/movie/tt1.json"));
|
||||||
|
assert!(subtitles.contains("videoHash=abc"));
|
||||||
|
assert!(subtitles.contains("filename=File%20Name.mkv"));
|
||||||
|
|
||||||
|
let catalog = addon_resource_request_plan_json(
|
||||||
|
r#"{"transportUrl":"https://addon.example/manifest.json","resource":"catalog","contentType":"movie","id":"top","extraArgs":{"skip":"100","search":"matrix"}}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(catalog.contains("catalog/movie/top/"));
|
||||||
|
assert!(catalog.contains("skip=100"));
|
||||||
|
assert!(catalog.contains("search=matrix"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stream_provider_normalization_merges_headers_and_hints_without_reordering() {
|
||||||
|
let streams = addon_streams_with_provider_json(
|
||||||
|
r#"[{"title":"A","behaviorHints":{"videoHash":"abc","videoSize":12,"proxyHeaders":{"request":{"X-Proxy":"1"}}}},{"title":"B"}]"#,
|
||||||
|
"Addon",
|
||||||
|
);
|
||||||
|
let value: Value = serde_json::from_str(&streams).unwrap();
|
||||||
|
assert_eq!(value[0]["title"].as_str(), Some("A"));
|
||||||
|
assert_eq!(value[0]["addonName"].as_str(), Some("Addon"));
|
||||||
|
assert_eq!(value[0]["videoHash"].as_str(), Some("abc"));
|
||||||
|
assert_eq!(value[0]["videoSize"].as_i64(), Some(12));
|
||||||
|
assert_eq!(
|
||||||
|
value[0]["behaviorHints"]["requestHeaders"]["X-Proxy"].as_str(),
|
||||||
|
Some("1")
|
||||||
|
);
|
||||||
|
assert_eq!(value[1]["title"].as_str(), Some("B"));
|
||||||
|
}
|
||||||
|
}
|
||||||
226
src/runtime/effects.rs
Normal file
226
src/runtime/effects.rs
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Exhaustive catalog of all effect types the headless engine can emit.
|
||||||
|
///
|
||||||
|
/// This is the single source of truth for effect type names — the string
|
||||||
|
/// representations produced by `as_str()` are the ones the platform (Kotlin,
|
||||||
|
/// JS, etc.) matches against in its effect dispatcher.
|
||||||
|
///
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum EffectKind {
|
||||||
|
ClearPlaybackProgress,
|
||||||
|
EnqueueOfflineDownload,
|
||||||
|
EnqueueTraktScrobble,
|
||||||
|
ExchangeAuthCode,
|
||||||
|
FetchAddonManifest,
|
||||||
|
FetchAddonResource,
|
||||||
|
FetchCatalogPage,
|
||||||
|
FetchDetailSecondary,
|
||||||
|
FetchDetailStreams,
|
||||||
|
FetchIntroSegments,
|
||||||
|
FetchMetaDetail,
|
||||||
|
FetchMetaDetailLookup,
|
||||||
|
FetchSeasonEpisodes,
|
||||||
|
FetchSubtitles,
|
||||||
|
NotifyReleasedEpisodes,
|
||||||
|
PrefetchDetailStreams,
|
||||||
|
PrefetchNextEpisodeStreams,
|
||||||
|
PrepareDirectPlayback,
|
||||||
|
ReadCalendarMonth,
|
||||||
|
ReadDetailLocalState,
|
||||||
|
ReadDiscoverCatalogFilters,
|
||||||
|
ReadHomeBootstrap,
|
||||||
|
ReadLibraryState,
|
||||||
|
ReadPlaybackProgress,
|
||||||
|
RefreshAuthToken,
|
||||||
|
RefreshInstalledAddons,
|
||||||
|
ReplaceExternalContinueWatching,
|
||||||
|
ResolveIntroImdbId,
|
||||||
|
RunAuthFlow,
|
||||||
|
RunDiscover,
|
||||||
|
RunExternalSync,
|
||||||
|
RunSearch,
|
||||||
|
StartTorrentStream,
|
||||||
|
StopTorrent,
|
||||||
|
SyncExternalIntegration,
|
||||||
|
SyncWatchedState,
|
||||||
|
UpdateCalendarWidget,
|
||||||
|
WriteFeedback,
|
||||||
|
WriteLibraryCommand,
|
||||||
|
WritePlaybackProgress,
|
||||||
|
WriteSettings,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EffectKind {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
EffectKind::ClearPlaybackProgress => "clearPlaybackProgress",
|
||||||
|
EffectKind::EnqueueOfflineDownload => "enqueueOfflineDownload",
|
||||||
|
EffectKind::EnqueueTraktScrobble => "enqueueTraktScrobble",
|
||||||
|
EffectKind::ExchangeAuthCode => "exchangeAuthCode",
|
||||||
|
EffectKind::FetchAddonManifest => "fetchAddonManifest",
|
||||||
|
EffectKind::FetchAddonResource => "fetchAddonResource",
|
||||||
|
EffectKind::FetchCatalogPage => "fetchCatalogPage",
|
||||||
|
EffectKind::FetchDetailSecondary => "fetchDetailSecondary",
|
||||||
|
EffectKind::FetchDetailStreams => "fetchDetailStreams",
|
||||||
|
EffectKind::FetchIntroSegments => "fetchIntroSegments",
|
||||||
|
EffectKind::FetchMetaDetail => "fetchMetaDetail",
|
||||||
|
EffectKind::FetchMetaDetailLookup => "fetchMetaDetailLookup",
|
||||||
|
EffectKind::FetchSeasonEpisodes => "fetchSeasonEpisodes",
|
||||||
|
EffectKind::FetchSubtitles => "fetchSubtitles",
|
||||||
|
EffectKind::NotifyReleasedEpisodes => "notifyReleasedEpisodes",
|
||||||
|
EffectKind::PrefetchDetailStreams => "prefetchDetailStreams",
|
||||||
|
EffectKind::PrefetchNextEpisodeStreams => "prefetchNextEpisodeStreams",
|
||||||
|
EffectKind::PrepareDirectPlayback => "prepareDirectPlayback",
|
||||||
|
EffectKind::ReadCalendarMonth => "readCalendarMonth",
|
||||||
|
EffectKind::ReadDetailLocalState => "readDetailLocalState",
|
||||||
|
EffectKind::ReadDiscoverCatalogFilters => "readDiscoverCatalogFilters",
|
||||||
|
EffectKind::ReadHomeBootstrap => "readHomeBootstrap",
|
||||||
|
EffectKind::ReadLibraryState => "readLibraryState",
|
||||||
|
EffectKind::ReadPlaybackProgress => "readPlaybackProgress",
|
||||||
|
EffectKind::RefreshAuthToken => "refreshAuthToken",
|
||||||
|
EffectKind::RefreshInstalledAddons => "refreshInstalledAddons",
|
||||||
|
EffectKind::ReplaceExternalContinueWatching => "replaceExternalContinueWatching",
|
||||||
|
EffectKind::ResolveIntroImdbId => "resolveIntroImdbId",
|
||||||
|
EffectKind::RunAuthFlow => "runAuthFlow",
|
||||||
|
EffectKind::RunDiscover => "runDiscover",
|
||||||
|
EffectKind::RunExternalSync => "runExternalSync",
|
||||||
|
EffectKind::RunSearch => "runSearch",
|
||||||
|
EffectKind::StartTorrentStream => "startTorrentStream",
|
||||||
|
EffectKind::StopTorrent => "stopTorrent",
|
||||||
|
EffectKind::SyncExternalIntegration => "syncExternalIntegration",
|
||||||
|
EffectKind::SyncWatchedState => "syncWatchedState",
|
||||||
|
EffectKind::UpdateCalendarWidget => "updateCalendarWidget",
|
||||||
|
EffectKind::WriteFeedback => "writeFeedback",
|
||||||
|
EffectKind::WriteLibraryCommand => "writeLibraryCommand",
|
||||||
|
EffectKind::WritePlaybackProgress => "writePlaybackProgress",
|
||||||
|
EffectKind::WriteSettings => "writeSettings",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire format for an effect emitted by the headless engine.
|
||||||
|
///
|
||||||
|
/// Matches the `NativeHeadlessEffect` data class on the Kotlin side:
|
||||||
|
/// ```kotlin
|
||||||
|
/// data class NativeHeadlessEffect(
|
||||||
|
/// val id: String,
|
||||||
|
/// val type: String,
|
||||||
|
/// val generation: Long,
|
||||||
|
/// val payload: Map<String, Any?>
|
||||||
|
/// )
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// `id` is a monotonically-increasing opaque string (`"fx-N"`).
|
||||||
|
/// `generation` lets the platform discard stale completions.
|
||||||
|
/// `payload` carries effect-specific parameters as a JSON object.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct EffectEnvelope {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub kind: String,
|
||||||
|
pub generation: u64,
|
||||||
|
pub payload: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EffectEnvelope {
|
||||||
|
pub fn new(id: String, kind: EffectKind, generation: u64, payload: serde_json::Value) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
kind: kind.as_str().to_owned(),
|
||||||
|
generation,
|
||||||
|
payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn raw(id: String, kind: &str, generation: u64, payload: serde_json::Value) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
kind: kind.to_owned(),
|
||||||
|
generation,
|
||||||
|
payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed effect emitted by a portable engine model for the platform to execute.
|
||||||
|
///
|
||||||
|
/// Mirrors stremio-core's `Effect` enum. Each variant carries fully-typed
|
||||||
|
/// payload fields, making it compile-time verified and WASM-safe.
|
||||||
|
///
|
||||||
|
/// The headless engine currently serializes effects through `EffectEnvelope`
|
||||||
|
/// (untyped payload). This typed enum is the long-term migration target for
|
||||||
|
/// models that have been fully ported away from `serde_json::Value` state.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
|
pub enum Effect {
|
||||||
|
FetchAddonResource {
|
||||||
|
effect_id: String,
|
||||||
|
url: String,
|
||||||
|
timeout_ms: u32,
|
||||||
|
},
|
||||||
|
FetchAddonManifest {
|
||||||
|
effect_id: String,
|
||||||
|
url: String,
|
||||||
|
timeout_ms: u32,
|
||||||
|
},
|
||||||
|
FetchCatalogPage {
|
||||||
|
effect_id: String,
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
FetchMetaDetail {
|
||||||
|
effect_id: String,
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
FetchStreams {
|
||||||
|
effect_id: String,
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
FetchSubtitles {
|
||||||
|
effect_id: String,
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
GetStorage {
|
||||||
|
effect_id: String,
|
||||||
|
key: String,
|
||||||
|
},
|
||||||
|
SetStorage {
|
||||||
|
effect_id: String,
|
||||||
|
key: String,
|
||||||
|
value: Option<String>,
|
||||||
|
},
|
||||||
|
Log {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Effects {
|
||||||
|
pub effects: Vec<Effect>,
|
||||||
|
pub has_changed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effects {
|
||||||
|
pub fn none() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn changed() -> Self {
|
||||||
|
Self {
|
||||||
|
effects: vec![],
|
||||||
|
has_changed: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_effect(mut self, effect: Effect) -> Self {
|
||||||
|
self.effects.push(effect);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn merge(mut self, other: Effects) -> Self {
|
||||||
|
self.effects.extend(other.effects);
|
||||||
|
self.has_changed = self.has_changed || other.has_changed;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/runtime/mod.rs
Normal file
7
src/runtime/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
pub mod effects;
|
||||||
|
pub mod msg;
|
||||||
|
pub mod update;
|
||||||
|
|
||||||
|
pub use effects::{Effect, EffectEnvelope, EffectKind, Effects};
|
||||||
|
pub use msg::Msg;
|
||||||
|
pub use update::Update;
|
||||||
47
src/runtime/msg/action.rs
Normal file
47
src/runtime/msg/action.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
|
pub enum Action {
|
||||||
|
LoadHome,
|
||||||
|
LoadCatalog {
|
||||||
|
addon_transport_url: String,
|
||||||
|
catalog_id: String,
|
||||||
|
content_type: String,
|
||||||
|
},
|
||||||
|
LoadMetaDetail {
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
LoadStreams {
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
Search {
|
||||||
|
query: String,
|
||||||
|
},
|
||||||
|
SetProfile {
|
||||||
|
profile_json: String,
|
||||||
|
},
|
||||||
|
SyncLibrary,
|
||||||
|
PlayerStarted {
|
||||||
|
content_type: String,
|
||||||
|
id: String,
|
||||||
|
video_id: Option<String>,
|
||||||
|
},
|
||||||
|
PlayerStopped,
|
||||||
|
PlayerProgressUpdate {
|
||||||
|
position_ms: i64,
|
||||||
|
duration_ms: i64,
|
||||||
|
},
|
||||||
|
InstallAddon {
|
||||||
|
transport_url: String,
|
||||||
|
},
|
||||||
|
UninstallAddon {
|
||||||
|
transport_url: String,
|
||||||
|
},
|
||||||
|
LibraryItemWatched {
|
||||||
|
id: String,
|
||||||
|
watched: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
25
src/runtime/msg/event.rs
Normal file
25
src/runtime/msg/event.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
|
pub enum Event {
|
||||||
|
ProfileChanged,
|
||||||
|
LibrarySynced,
|
||||||
|
AddonInstalled {
|
||||||
|
transport_url: String,
|
||||||
|
},
|
||||||
|
AddonUninstalled {
|
||||||
|
transport_url: String,
|
||||||
|
},
|
||||||
|
PlayerStopped {
|
||||||
|
content_type: Option<String>,
|
||||||
|
id: Option<String>,
|
||||||
|
video_id: Option<String>,
|
||||||
|
duration_ms: i64,
|
||||||
|
time_offset_ms: i64,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
code: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
31
src/runtime/msg/internal.rs
Normal file
31
src/runtime/msg/internal.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "camelCase")]
|
||||||
|
pub enum Internal {
|
||||||
|
AddonResourceFetched {
|
||||||
|
effect_id: String,
|
||||||
|
url: String,
|
||||||
|
status_code: u16,
|
||||||
|
body: Option<String>,
|
||||||
|
},
|
||||||
|
AddonManifestFetched {
|
||||||
|
effect_id: String,
|
||||||
|
url: String,
|
||||||
|
status_code: u16,
|
||||||
|
body: Option<String>,
|
||||||
|
},
|
||||||
|
StorageRead {
|
||||||
|
effect_id: String,
|
||||||
|
key: String,
|
||||||
|
value: Option<String>,
|
||||||
|
},
|
||||||
|
StorageWritten {
|
||||||
|
effect_id: String,
|
||||||
|
key: String,
|
||||||
|
},
|
||||||
|
HttpError {
|
||||||
|
effect_id: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
17
src/runtime/msg/mod.rs
Normal file
17
src/runtime/msg/mod.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
pub mod action;
|
||||||
|
pub mod event;
|
||||||
|
pub mod internal;
|
||||||
|
|
||||||
|
pub use action::Action;
|
||||||
|
pub use event::Event;
|
||||||
|
pub use internal::Internal;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "kind", content = "payload", rename_all = "camelCase")]
|
||||||
|
pub enum Msg {
|
||||||
|
Action(Action),
|
||||||
|
Internal(Internal),
|
||||||
|
Event(Event),
|
||||||
|
}
|
||||||
9
src/runtime/update.rs
Normal file
9
src/runtime/update.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
use crate::runtime::effects::Effects;
|
||||||
|
use crate::runtime::msg::Msg;
|
||||||
|
|
||||||
|
/// Mirrors stremio-core's `Update` trait. The engine calls `update` when a
|
||||||
|
/// new `Msg` arrives; the model mutates its own state and returns a set of
|
||||||
|
/// side-effect requests for the platform to execute.
|
||||||
|
pub trait Update {
|
||||||
|
fn update(&mut self, msg: &Msg) -> Effects;
|
||||||
|
}
|
||||||
633
src/search_plan.rs
Normal file
633
src/search_plan.rs
Normal file
|
|
@ -0,0 +1,633 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
use crate::{addon_protocol, content_identity};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SearchGroupingRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
results: Vec<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
query: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct DiscoverSortRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
items: Vec<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
sort_by: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
ascending: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
content_type_filter: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
genre_filter: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct LibrarySortRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
items: Vec<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
sort_by: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
ascending: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
type_filter: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
status_filter: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_value(addon: &Value) -> Option<&Value> {
|
||||||
|
addon.get("manifest").or_else(|| Some(addon))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn addon_transport_url(addon: &Value) -> &str {
|
||||||
|
addon.get("transportUrl").and_then(Value::as_str).unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn addon_manifest_name(addon: &Value) -> String {
|
||||||
|
let manifest = manifest_value(addon).unwrap_or(addon);
|
||||||
|
manifest
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.or_else(|| manifest.get("id").and_then(Value::as_str))
|
||||||
|
.unwrap_or("Metadata")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title_label(value: &str) -> String {
|
||||||
|
let label = value
|
||||||
|
.split(['_', '-', ' '])
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.map(|part| {
|
||||||
|
let mut chars = part.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
if label.is_empty() {
|
||||||
|
value.to_string()
|
||||||
|
} else {
|
||||||
|
label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_feed_home_title(label: &str) -> String {
|
||||||
|
let parts = label
|
||||||
|
.split(" - ")
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
match parts.len() {
|
||||||
|
0 => label.to_string(),
|
||||||
|
1 => parts[0].to_string(),
|
||||||
|
2 => parts[1].to_string(),
|
||||||
|
_ => parts[1..].join(" "),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_catalog_label(raw_name: Option<&str>, id: &str) -> String {
|
||||||
|
let fallback = title_label(id);
|
||||||
|
let base = raw_name
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(&fallback);
|
||||||
|
let mut label = base
|
||||||
|
.split(['-', ':', '|', '/'])
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
for word in ["cinemeta", "movie", "movies", "film", "films", "series", "shows", "tv"] {
|
||||||
|
label = label
|
||||||
|
.split_whitespace()
|
||||||
|
.filter(|part| !part.eq_ignore_ascii_case(word))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
if label.trim().is_empty() {
|
||||||
|
fallback
|
||||||
|
} else {
|
||||||
|
label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn catalog_extra_options(catalog: &Value, extra_name: &str) -> Vec<String> {
|
||||||
|
catalog
|
||||||
|
.get("extra")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter(|extra| {
|
||||||
|
extra
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|name| name.eq_ignore_ascii_case(extra_name))
|
||||||
|
})
|
||||||
|
.flat_map(|extra| {
|
||||||
|
extra
|
||||||
|
.get("options")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
.filter_map(|value| value.as_str().map(str::trim).map(str::to_string))
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn catalog_genres(catalog: &Value) -> Vec<String> {
|
||||||
|
let mut genres = catalog
|
||||||
|
.get("genres")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|value| value.as_str().map(str::trim).map(str::to_string))
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for option in catalog_extra_options(catalog, "genre") {
|
||||||
|
if !genres.contains(&option) {
|
||||||
|
genres.push(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
genres
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_supports_catalog(manifest: &Value) -> bool {
|
||||||
|
serde_json::to_string(manifest)
|
||||||
|
.ok()
|
||||||
|
.is_some_and(|json| addon_protocol::supports_resource(&json, "catalog", None, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn catalog_has_required_extra_except(catalog: &Value, allowed: &[&str]) -> bool {
|
||||||
|
let allowed_json = serde_json::to_string(&allowed.iter().map(|s| s.to_string()).collect::<Vec<_>>())
|
||||||
|
.unwrap_or_else(|_| "[]".to_string());
|
||||||
|
serde_json::to_string(catalog)
|
||||||
|
.ok()
|
||||||
|
.is_some_and(|json| addon_protocol::catalog_has_required_extra_except(&json, &allowed_json))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn catalog_requires_extra(catalog: &Value, extra_name: &str) -> bool {
|
||||||
|
serde_json::to_string(catalog)
|
||||||
|
.ok()
|
||||||
|
.is_some_and(|json| addon_protocol::catalog_requires_extra(&json, extra_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_metadata_feed_options_json(addons_json: &str) -> Option<String> {
|
||||||
|
let addons = serde_json::from_str::<Vec<Value>>(addons_json).ok()?;
|
||||||
|
let mut feeds = Vec::new();
|
||||||
|
for addon in addons {
|
||||||
|
let Some(manifest) = manifest_value(&addon) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !manifest_supports_catalog(manifest) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let addon_name = addon_manifest_name(&addon);
|
||||||
|
let transport_url = addon_transport_url(&addon);
|
||||||
|
let source_key = manifest
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(transport_url);
|
||||||
|
for catalog in manifest
|
||||||
|
.get("catalogs")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
let Some(type_value) = catalog
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(id) = catalog
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if catalog_has_required_extra_except(catalog, &[]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = catalog
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| title_label(id));
|
||||||
|
let key = format!(
|
||||||
|
"addon:{}:{}:{}",
|
||||||
|
content_identity::stable_feed_part(source_key),
|
||||||
|
content_identity::stable_feed_part(type_value),
|
||||||
|
content_identity::stable_feed_part(id)
|
||||||
|
);
|
||||||
|
let label = format!("{addon_name} - {name}");
|
||||||
|
feeds.push(json!({
|
||||||
|
"key": key,
|
||||||
|
"label": label,
|
||||||
|
"homeTitle": metadata_feed_home_title(&label),
|
||||||
|
"transportUrl": transport_url,
|
||||||
|
"type": type_value,
|
||||||
|
"id": id,
|
||||||
|
"genre": Value::Null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::to_string(&feeds).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn discover_catalog_options_json(addons_json: &str, selected_type: &str) -> Option<String> {
|
||||||
|
let addons = serde_json::from_str::<Vec<Value>>(addons_json).ok()?;
|
||||||
|
let normalized_type = selected_type.to_lowercase();
|
||||||
|
let mut options = Vec::new();
|
||||||
|
for addon in addons {
|
||||||
|
let Some(manifest) = manifest_value(&addon) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !manifest_supports_catalog(manifest) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let transport_url = addon_transport_url(&addon);
|
||||||
|
let source_key = manifest
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(transport_url);
|
||||||
|
for catalog in manifest
|
||||||
|
.get("catalogs")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
let Some(type_value) = catalog
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(content_identity::normalize_content_type)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if normalized_type != "all" && normalized_type != type_value {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(id) = catalog
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if catalog_has_required_extra_except(catalog, &["genre"]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let label = discover_catalog_label(catalog.get("name").and_then(Value::as_str), id);
|
||||||
|
let genres = catalog_genres(catalog);
|
||||||
|
options.push(json!({
|
||||||
|
"key": format!(
|
||||||
|
"discover:{}:{}:{}",
|
||||||
|
content_identity::stable_feed_part(source_key),
|
||||||
|
content_identity::stable_feed_part(type_value),
|
||||||
|
content_identity::stable_feed_part(&label)
|
||||||
|
),
|
||||||
|
"label": label,
|
||||||
|
"transportUrl": transport_url,
|
||||||
|
"type": type_value,
|
||||||
|
"id": id,
|
||||||
|
"genres": genres,
|
||||||
|
"requiresGenre": catalog_requires_extra(catalog, "genre")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::to_string(&options).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn search_result_grouping_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<SearchGroupingRequest>(request_json).ok()?;
|
||||||
|
let mut movies: Vec<&Value> = Vec::new();
|
||||||
|
let mut series: Vec<&Value> = Vec::new();
|
||||||
|
let mut other: Vec<&Value> = Vec::new();
|
||||||
|
for item in &request.results {
|
||||||
|
match item.get("type").and_then(Value::as_str).unwrap_or("") {
|
||||||
|
"movie" => movies.push(item),
|
||||||
|
"series" | "anime" => series.push(item),
|
||||||
|
_ => other.push(item),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut groups = Vec::new();
|
||||||
|
if !movies.is_empty() {
|
||||||
|
groups.push(json!({ "type": "movie", "items": movies }));
|
||||||
|
}
|
||||||
|
if !series.is_empty() {
|
||||||
|
groups.push(json!({ "type": "series", "items": series }));
|
||||||
|
}
|
||||||
|
if !other.is_empty() {
|
||||||
|
groups.push(json!({ "type": "other", "items": other }));
|
||||||
|
}
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"groups": groups,
|
||||||
|
"totalCount": request.results.len(),
|
||||||
|
"query": request.query
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn discover_sort_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<DiscoverSortRequest>(request_json).ok()?;
|
||||||
|
let content_type = request.content_type_filter.as_deref().unwrap_or("");
|
||||||
|
let genre = request.genre_filter.as_deref().unwrap_or("").to_lowercase();
|
||||||
|
let sort_by = request.sort_by.as_deref().unwrap_or("default");
|
||||||
|
|
||||||
|
let mut filtered: Vec<&Value> = request
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| {
|
||||||
|
let type_ok = content_type.is_empty()
|
||||||
|
|| item
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|t| t == content_type);
|
||||||
|
let genre_ok = genre.is_empty()
|
||||||
|
|| item
|
||||||
|
.get("genres")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.is_some_and(|g| {
|
||||||
|
g.iter()
|
||||||
|
.any(|gv| gv.as_str().is_some_and(|s| s.to_lowercase() == genre))
|
||||||
|
});
|
||||||
|
type_ok && genre_ok
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match sort_by {
|
||||||
|
"year" => {
|
||||||
|
filtered.sort_by(|a, b| {
|
||||||
|
let ya = a.get("releaseInfo").and_then(Value::as_str).and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
|
||||||
|
let yb = b.get("releaseInfo").and_then(Value::as_str).and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
|
||||||
|
if request.ascending { ya.cmp(&yb) } else { yb.cmp(&ya) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"rating" => {
|
||||||
|
filtered.sort_by(|a, b| {
|
||||||
|
let ra = a.get("imdbRating").and_then(Value::as_f64).unwrap_or(0.0);
|
||||||
|
let rb = b.get("imdbRating").and_then(Value::as_f64).unwrap_or(0.0);
|
||||||
|
if request.ascending {
|
||||||
|
ra.partial_cmp(&rb).unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
} else {
|
||||||
|
rb.partial_cmp(&ra).unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"name" => {
|
||||||
|
filtered.sort_by(|a, b| {
|
||||||
|
let na = a.get("name").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let nb = b.get("name").and_then(Value::as_str).unwrap_or("");
|
||||||
|
if request.ascending { na.cmp(nb) } else { nb.cmp(na) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"items": filtered,
|
||||||
|
"sortBy": sort_by,
|
||||||
|
"ascending": request.ascending,
|
||||||
|
"totalCount": filtered.len()
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn library_sort_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<LibrarySortRequest>(request_json).ok()?;
|
||||||
|
let type_filter = request.type_filter.as_deref().unwrap_or("").to_lowercase();
|
||||||
|
let status_filter = request.status_filter.as_deref().unwrap_or("").to_lowercase();
|
||||||
|
let sort_by = request.sort_by.as_deref().unwrap_or("added");
|
||||||
|
|
||||||
|
let mut filtered: Vec<&Value> = request
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| {
|
||||||
|
let type_ok = type_filter.is_empty()
|
||||||
|
|| item
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|t| t.to_lowercase() == type_filter);
|
||||||
|
let status_ok = status_filter.is_empty()
|
||||||
|
|| item
|
||||||
|
.get("status")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|s| s.to_lowercase() == status_filter);
|
||||||
|
type_ok && status_ok
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
match sort_by {
|
||||||
|
"name" => {
|
||||||
|
filtered.sort_by(|a, b| {
|
||||||
|
let na = a.get("name").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let nb = b.get("name").and_then(Value::as_str).unwrap_or("");
|
||||||
|
if request.ascending { na.cmp(nb) } else { nb.cmp(na) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"year" => {
|
||||||
|
filtered.sort_by(|a, b| {
|
||||||
|
let ya = a.get("releaseInfo").and_then(Value::as_str).and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
|
||||||
|
let yb = b.get("releaseInfo").and_then(Value::as_str).and_then(|s| s.parse::<i32>().ok()).unwrap_or(0);
|
||||||
|
if request.ascending { ya.cmp(&yb) } else { yb.cmp(&ya) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"progress" => {
|
||||||
|
filtered.sort_by(|a, b| {
|
||||||
|
let pa = a.get("timeOffset").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let pb = b.get("timeOffset").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
if request.ascending { pa.cmp(&pb) } else { pb.cmp(&pa) }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"items": filtered,
|
||||||
|
"sortBy": sort_by,
|
||||||
|
"totalCount": filtered.len()
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn detail_series_lookup_id(raw_id: &str) -> String {
|
||||||
|
let trimmed = raw_id.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
if let Some(imdb) = extract_imdb_id(trimmed) {
|
||||||
|
return imdb;
|
||||||
|
}
|
||||||
|
// Strip trailing season:episode parts (e.g. "kitsu:777:1:2" -> "kitsu:777", "base:1:2" -> "base")
|
||||||
|
let parts: Vec<&str> = trimmed.split(':').collect();
|
||||||
|
if parts.len() >= 3 {
|
||||||
|
let last = parts[parts.len() - 1];
|
||||||
|
let second_last = parts[parts.len() - 2];
|
||||||
|
if last.parse::<i32>().is_ok() && second_last.parse::<i32>().is_ok() {
|
||||||
|
return parts[..parts.len() - 2].join(":");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_imdb_id(raw: &str) -> Option<String> {
|
||||||
|
let mut start = 0;
|
||||||
|
let bytes = raw.as_bytes();
|
||||||
|
while start < bytes.len() {
|
||||||
|
if bytes[start] == b't' && start + 2 < bytes.len() && bytes[start + 1] == b't' {
|
||||||
|
let end = bytes[start..]
|
||||||
|
.iter()
|
||||||
|
.take_while(|&&b| b.is_ascii_digit() || (b == b't' && start == 0))
|
||||||
|
.count();
|
||||||
|
let candidate = &raw[start..start + end];
|
||||||
|
if candidate.starts_with("tt") && candidate[2..].chars().all(|c| c.is_ascii_digit()) && candidate.len() > 3 {
|
||||||
|
return Some(candidate.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn detail_season_load_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let value: Value = serde_json::from_str(request_json).ok()?;
|
||||||
|
let saved_video_id = value
|
||||||
|
.get("savedVideoId")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("");
|
||||||
|
let seasons_count = value
|
||||||
|
.get("seasonsCount")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.unwrap_or(1)
|
||||||
|
.max(1) as i32;
|
||||||
|
|
||||||
|
let saved_season = saved_video_id
|
||||||
|
.split(':')
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.parse::<i32>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let first_season = if saved_season > 0 && saved_season <= seasons_count {
|
||||||
|
saved_season
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
};
|
||||||
|
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"firstSeasonToLoad": first_season,
|
||||||
|
"savedSeason": if saved_season > 0 { json!(saved_season) } else { Value::Null }
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_grouping_separates_movies_series_and_other() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&search_result_grouping_json(
|
||||||
|
r#"{"query":"breaking","results":[
|
||||||
|
{"id":"tt1","type":"series","name":"Breaking Bad"},
|
||||||
|
{"id":"tt2","type":"movie","name":"Breaking"},
|
||||||
|
{"id":"tt3","type":"other","name":"Another"}
|
||||||
|
]}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let groups = result["groups"].as_array().unwrap();
|
||||||
|
assert_eq!(groups[0]["type"], "movie");
|
||||||
|
assert_eq!(groups[1]["type"], "series");
|
||||||
|
assert_eq!(groups[2]["type"], "other");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discover_sort_filters_by_content_type() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&discover_sort_plan_json(
|
||||||
|
r#"{"contentTypeFilter":"movie","items":[{"id":"tt1","type":"movie"},{"id":"tt2","type":"series"}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result["items"].as_array().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn library_sort_filters_by_type() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&library_sort_plan_json(
|
||||||
|
r#"{"typeFilter":"movie","items":[{"id":"tt1","type":"movie"},{"id":"tt2","type":"series"}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result["items"].as_array().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_feed_options_preserve_custom_stremio_catalog_types() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&build_metadata_feed_options_json(
|
||||||
|
r#"[{"transportUrl":"https://aio.example/stremio/u/manifest.json","manifest":{"id":"aiometadata","name":"AIOMetadata","resources":["catalog"],"catalogs":[{"type":"anime.movie","id":"mal.top","name":"MAL Top"},{"type":"Trakt","id":"trakt.upnext","name":"Up Next"}]}}]"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let feeds = result.as_array().unwrap();
|
||||||
|
assert_eq!(feeds.len(), 2);
|
||||||
|
assert_eq!(feeds[0]["type"], "anime.movie");
|
||||||
|
assert_eq!(feeds[1]["type"], "Trakt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn series_lookup_id_extracts_imdb_id() {
|
||||||
|
assert_eq!(detail_series_lookup_id("tt1234567:1:2"), "tt1234567");
|
||||||
|
assert_eq!(detail_series_lookup_id("tt9999999"), "tt9999999");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn series_lookup_id_strips_episode_parts_for_non_imdb() {
|
||||||
|
assert_eq!(detail_series_lookup_id("kitsu:777:1:2"), "kitsu:777");
|
||||||
|
assert_eq!(detail_series_lookup_id("tmdb:12345:1:2"), "tmdb:12345");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn season_load_plan_uses_saved_season_when_valid() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&detail_season_load_plan_json(
|
||||||
|
r#"{"savedVideoId":"tt1:3:2","seasonsCount":5}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result["firstSeasonToLoad"], 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn season_load_plan_defaults_to_season_1_when_no_saved() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&detail_season_load_plan_json(r#"{"seasonsCount":5}"#).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result["firstSeasonToLoad"], 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
949
src/stream_policy.rs
Normal file
949
src/stream_policy.rs
Normal file
|
|
@ -0,0 +1,949 @@
|
||||||
|
use crate::content_identity::stream_matches_episode;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
const STREAM_SOURCE_MODE_FIRST: &str = "first";
|
||||||
|
const STREAM_SOURCE_MODE_REGEX: &str = "regex";
|
||||||
|
|
||||||
|
pub(crate) fn stream_behavior_text<'a>(stream: &'a Value, key: &str) -> Option<&'a str> {
|
||||||
|
stream
|
||||||
|
.get("behaviorHints")
|
||||||
|
.and_then(|hints| hints.get(key))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stream_text<'a>(stream: &'a Value, key: &str) -> Option<&'a str> {
|
||||||
|
stream
|
||||||
|
.get(key)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stream_number(stream: &Value, key: &str) -> Option<i64> {
|
||||||
|
stream.get(key).and_then(Value::as_i64).or_else(|| {
|
||||||
|
stream
|
||||||
|
.get("behaviorHints")
|
||||||
|
.and_then(|hints| hints.get(key))
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stream_playable_url(stream: &Value) -> Option<String> {
|
||||||
|
if let Some(url) = stream_text(stream, "url") {
|
||||||
|
return Some(url.to_string());
|
||||||
|
}
|
||||||
|
if let Some(yt_id) = stream_text(stream, "ytId") {
|
||||||
|
return Some(format!("https://www.youtube.com/watch?v={yt_id}"));
|
||||||
|
}
|
||||||
|
if let Some(yt_id) = stream_text(stream, "yt_ID") {
|
||||||
|
return Some(format!("https://www.youtube.com/watch?v={yt_id}"));
|
||||||
|
}
|
||||||
|
if let Some(external_url) = stream_text(stream, "externalUrl") {
|
||||||
|
return Some(external_url.to_string());
|
||||||
|
}
|
||||||
|
let info_hash = stream_text(stream, "infoHash")?;
|
||||||
|
match stream.get("fileIdx").and_then(Value::as_i64) {
|
||||||
|
Some(file_idx) => Some(format!("stremio://torrent/{info_hash}/{file_idx}")),
|
||||||
|
None => Some(format!("stremio://torrent/{info_hash}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn percent_decode_component(value: &str) -> String {
|
||||||
|
let mut bytes = Vec::with_capacity(value.len());
|
||||||
|
let raw = value.as_bytes();
|
||||||
|
let mut index = 0;
|
||||||
|
while index < raw.len() {
|
||||||
|
if raw[index] == b'%' && index + 2 < raw.len() {
|
||||||
|
if let Ok(hex) = u8::from_str_radix(&value[index + 1..index + 3], 16) {
|
||||||
|
bytes.push(hex);
|
||||||
|
index += 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bytes.push(raw[index]);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&bytes).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stream_effective_filename(
|
||||||
|
stream: &Value,
|
||||||
|
playable_url: Option<&str>,
|
||||||
|
) -> Option<String> {
|
||||||
|
if let Some(filename) = stream_text(stream, "filename") {
|
||||||
|
return Some(filename.to_string());
|
||||||
|
}
|
||||||
|
if let Some(filename) = stream_behavior_text(stream, "filename") {
|
||||||
|
return Some(filename.to_string());
|
||||||
|
}
|
||||||
|
let url = stream_text(stream, "url").or(playable_url)?;
|
||||||
|
let path = url
|
||||||
|
.split('?')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(url)
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.rsplit('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("");
|
||||||
|
if path.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(percent_decode_component(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn form_encode(value: &str) -> String {
|
||||||
|
let mut encoded = String::with_capacity(value.len());
|
||||||
|
for byte in value.bytes() {
|
||||||
|
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'*') {
|
||||||
|
encoded.push(byte as char);
|
||||||
|
} else if byte == b' ' {
|
||||||
|
encoded.push('+');
|
||||||
|
} else {
|
||||||
|
encoded.push_str(&format!("%{byte:02X}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_torrent_playback_url(value: &str) -> bool {
|
||||||
|
value.starts_with("stremio://torrent/")
|
||||||
|
|| value.starts_with("magnet:")
|
||||||
|
|| value.starts_with("infohash:")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stream_is_likely_player_compatible(
|
||||||
|
_stream: &Value,
|
||||||
|
playable_url: Option<&str>,
|
||||||
|
_effective_filename: Option<&str>,
|
||||||
|
) -> bool {
|
||||||
|
let Some(candidate) = playable_url
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let normalized = candidate.to_ascii_lowercase();
|
||||||
|
if is_torrent_playback_url(&normalized) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if !normalized.starts_with("http://") && !normalized.starts_with("https://") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stream_playback_info_json(stream_json: &str) -> Option<String> {
|
||||||
|
let stream = serde_json::from_str::<Value>(stream_json).ok()?;
|
||||||
|
let playable_url = stream_playable_url(&stream);
|
||||||
|
let effective_video_hash = stream_text(&stream, "videoHash")
|
||||||
|
.or_else(|| stream_behavior_text(&stream, "videoHash"))
|
||||||
|
.map(str::to_string);
|
||||||
|
let effective_video_size =
|
||||||
|
stream_number(&stream, "videoSize").or_else(|| stream_number(&stream, "size"));
|
||||||
|
let effective_filename = stream_effective_filename(&stream, playable_url.as_deref());
|
||||||
|
let subtitle_parts = [
|
||||||
|
effective_video_hash
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| ("videoHash", value.clone())),
|
||||||
|
effective_video_size.map(|value| ("videoSize", value.to_string())),
|
||||||
|
effective_filename
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| ("filename", value.clone())),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.map(|(key, value)| format!("{}={}", form_encode(key), form_encode(&value)))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let is_torrent = playable_url
|
||||||
|
.as_deref()
|
||||||
|
.map(is_torrent_playback_url)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let is_compatible = stream_is_likely_player_compatible(
|
||||||
|
&stream,
|
||||||
|
playable_url.as_deref(),
|
||||||
|
effective_filename.as_deref(),
|
||||||
|
);
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"playableUrl": playable_url,
|
||||||
|
"effectiveVideoHash": effective_video_hash,
|
||||||
|
"effectiveVideoSize": effective_video_size,
|
||||||
|
"effectiveFilename": effective_filename,
|
||||||
|
"subtitleExtraArgs": subtitle_parts.join("&"),
|
||||||
|
"isTorrentPlaybackUrl": is_torrent,
|
||||||
|
"isLikelyPlayerCompatible": is_compatible
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stream_request_headers_json(headers_json: &str) -> Option<String> {
|
||||||
|
let headers = serde_json::from_str::<HashMap<String, String>>(headers_json).ok()?;
|
||||||
|
let clean = headers
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(key, value)| !key.trim().is_empty() && !value.trim().is_empty())
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
serde_json::to_string(&clean).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stream_request_referer(_url: &str) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct TorrentRuntimeRequest {
|
||||||
|
link: String,
|
||||||
|
title: String,
|
||||||
|
requested_file_idx: Option<i32>,
|
||||||
|
preferred_filename: Option<String>,
|
||||||
|
sources: Vec<String>,
|
||||||
|
file_stats: Vec<TorrentFileStat>,
|
||||||
|
rejected_index: Option<i32>,
|
||||||
|
base_url: String,
|
||||||
|
play: bool,
|
||||||
|
stat: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, serde::Deserialize)]
|
||||||
|
pub(crate) struct TorrentFileStat {
|
||||||
|
id: i32,
|
||||||
|
path: String,
|
||||||
|
length: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_bare_info_hash(value: &str) -> bool {
|
||||||
|
let length = value.len();
|
||||||
|
matches!(length, 32 | 40 | 64) && value.chars().all(|ch| ch.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalize_torrent_link(link: &str, sources: &[String]) -> String {
|
||||||
|
let trimmed = link.trim();
|
||||||
|
let lower = trimmed.to_ascii_lowercase();
|
||||||
|
if lower.starts_with("stremio://torrent/") {
|
||||||
|
let rest = &trimmed["stremio://torrent/".len()..];
|
||||||
|
let hash = rest.split('/').next().unwrap_or("").trim();
|
||||||
|
if hash.is_empty() {
|
||||||
|
return trimmed.to_string();
|
||||||
|
}
|
||||||
|
return build_magnet(hash, sources);
|
||||||
|
}
|
||||||
|
if lower.starts_with("infohash:") {
|
||||||
|
return build_magnet(
|
||||||
|
trimmed
|
||||||
|
.split_once(':')
|
||||||
|
.map(|(_, value)| value)
|
||||||
|
.unwrap_or(""),
|
||||||
|
sources,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if is_bare_info_hash(trimmed) {
|
||||||
|
return build_magnet(trimmed, sources);
|
||||||
|
}
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popular fallback trackers always added to magnets so a bare info_hash (no
|
||||||
|
// addon-provided sources) doesn't have to round-trip DHT for peer discovery.
|
||||||
|
// Kept short — duplicates from `sources` are filtered out below.
|
||||||
|
const FALLBACK_TRACKERS: &[&str] = &[
|
||||||
|
"udp://tracker.opentrackr.org:1337/announce",
|
||||||
|
"udp://open.stealth.si:80/announce",
|
||||||
|
"udp://tracker.torrent.eu.org:451/announce",
|
||||||
|
"udp://exodus.desync.com:6969/announce",
|
||||||
|
"udp://open.demonii.com:1337/announce",
|
||||||
|
];
|
||||||
|
|
||||||
|
pub(crate) fn build_magnet(hash: &str, sources: &[String]) -> String {
|
||||||
|
let mut trackers = Vec::new();
|
||||||
|
for source in sources {
|
||||||
|
let tracker = source.strip_prefix("tracker:").unwrap_or(source).trim();
|
||||||
|
if (tracker.starts_with("udp://")
|
||||||
|
|| tracker.starts_with("http://")
|
||||||
|
|| tracker.starts_with("https://"))
|
||||||
|
&& !trackers.contains(&tracker.to_string())
|
||||||
|
{
|
||||||
|
trackers.push(tracker.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for fallback in FALLBACK_TRACKERS {
|
||||||
|
let tracker = fallback.to_string();
|
||||||
|
if !trackers.contains(&tracker) {
|
||||||
|
trackers.push(tracker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tracker_query = trackers
|
||||||
|
.iter()
|
||||||
|
.map(|tracker| format!("&tr={}", form_encode(tracker)))
|
||||||
|
.collect::<String>();
|
||||||
|
format!(
|
||||||
|
"magnet:?xt=urn:btih:{}{}",
|
||||||
|
hash.to_ascii_lowercase(),
|
||||||
|
tracker_query
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalize_torrent_file_name(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.replace('\\', "/")
|
||||||
|
.split_whitespace()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_likely_video_file(path: &str) -> bool {
|
||||||
|
let path = path.to_ascii_lowercase();
|
||||||
|
[".mkv", ".mp4", ".avi", ".webm", ".m4v", ".mov", ".ts"]
|
||||||
|
.iter()
|
||||||
|
.any(|extension| path.ends_with(extension))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_torrent_file_index(
|
||||||
|
_title: &str,
|
||||||
|
requested_file_idx: Option<i32>,
|
||||||
|
preferred_filename: Option<&str>,
|
||||||
|
file_stats: &[TorrentFileStat],
|
||||||
|
) -> (Option<i32>, Option<String>) {
|
||||||
|
// addon-provided fileIdx is authoritative — use it directly
|
||||||
|
if let Some(idx) = requested_file_idx {
|
||||||
|
return (Some(idx), Some("requested".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if file_stats.is_empty() {
|
||||||
|
return (None, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(preferred) = preferred_filename
|
||||||
|
.map(normalize_torrent_file_name)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
{
|
||||||
|
if let Some(stat) = file_stats.iter().find(|stat| {
|
||||||
|
let path = normalize_torrent_file_name(&stat.path);
|
||||||
|
path == preferred
|
||||||
|
|| path.ends_with(&format!("/{preferred}"))
|
||||||
|
|| path.rsplit('/').next() == Some(preferred.as_str())
|
||||||
|
}) {
|
||||||
|
return (Some(stat.id), Some("filename".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file_stats
|
||||||
|
.iter()
|
||||||
|
.filter(|stat| is_likely_video_file(&stat.path))
|
||||||
|
.max_by_key(|stat| stat.length)
|
||||||
|
.map(|stat| (Some(stat.id), Some("largest-video".to_string())))
|
||||||
|
.unwrap_or((None, None))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn torrent_fallback_file_indexes(
|
||||||
|
_title: &str,
|
||||||
|
rejected_index: Option<i32>,
|
||||||
|
file_stats: &[TorrentFileStat],
|
||||||
|
) -> Vec<i32> {
|
||||||
|
let mut videos: Vec<&TorrentFileStat> = file_stats
|
||||||
|
.iter()
|
||||||
|
.filter(|stat| is_likely_video_file(&stat.path))
|
||||||
|
.collect();
|
||||||
|
videos.sort_by_key(|stat| std::cmp::Reverse(stat.length));
|
||||||
|
videos
|
||||||
|
.into_iter()
|
||||||
|
.filter(|stat| Some(stat.id) != rejected_index)
|
||||||
|
.map(|stat| stat.id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn query_encode(value: &str) -> String {
|
||||||
|
form_encode(value).replace('+', "%20")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_torrent_stream_url(
|
||||||
|
base_url: &str,
|
||||||
|
link: &str,
|
||||||
|
title: &str,
|
||||||
|
file_idx: Option<i32>,
|
||||||
|
play: bool,
|
||||||
|
stat: bool,
|
||||||
|
) -> String {
|
||||||
|
let base = format!("{}/stream/fname", base_url.trim_end_matches('/'));
|
||||||
|
let mut query = format!("link={}", query_encode(link));
|
||||||
|
if let Some(index) = file_idx {
|
||||||
|
query.push_str(&format!("&index={index}"));
|
||||||
|
}
|
||||||
|
if play {
|
||||||
|
query.push_str("&play");
|
||||||
|
}
|
||||||
|
if stat {
|
||||||
|
query.push_str("&stat");
|
||||||
|
}
|
||||||
|
query.push_str(&format!("&title={}", query_encode(title)));
|
||||||
|
format!("{base}?{query}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn torrent_runtime_info_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<TorrentRuntimeRequest>(request_json).ok()?;
|
||||||
|
let normalized_link = normalize_torrent_link(&request.link, &request.sources);
|
||||||
|
let (selected_file_idx, selected_reason) = resolve_torrent_file_index(
|
||||||
|
&request.title,
|
||||||
|
request.requested_file_idx,
|
||||||
|
request.preferred_filename.as_deref(),
|
||||||
|
&request.file_stats,
|
||||||
|
);
|
||||||
|
let fallback_file_indexes =
|
||||||
|
torrent_fallback_file_indexes(&request.title, request.rejected_index, &request.file_stats);
|
||||||
|
let stream_url = build_torrent_stream_url(
|
||||||
|
&request.base_url,
|
||||||
|
&normalized_link,
|
||||||
|
&request.title,
|
||||||
|
selected_file_idx,
|
||||||
|
request.play,
|
||||||
|
request.stat,
|
||||||
|
);
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"normalizedLink": normalized_link,
|
||||||
|
"selectedFileIdx": selected_file_idx,
|
||||||
|
"selectedReason": selected_reason,
|
||||||
|
"fallbackFileIndexes": fallback_file_indexes,
|
||||||
|
"streamUrl": stream_url
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn torrent_buffer_progress(status: &Value) -> i32 {
|
||||||
|
let stat = status.get("stat").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let preload = status.get("preload").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let loaded_size = status
|
||||||
|
.get("loaded_size")
|
||||||
|
.or_else(|| status.get("loadedSize"))
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let preload_size = status
|
||||||
|
.get("preload_size")
|
||||||
|
.or_else(|| status.get("preloadSize"))
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let progress = status
|
||||||
|
.get("progress")
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
let value = if stat >= 3 {
|
||||||
|
100
|
||||||
|
} else if preload > 0 {
|
||||||
|
preload as i32
|
||||||
|
} else if preload_size > 0 {
|
||||||
|
((loaded_size as f64 / preload_size as f64) * 100.0) as i32
|
||||||
|
} else if loaded_size > 0 {
|
||||||
|
((loaded_size as f64 / (512.0 * 1024.0)) * 100.0) as i32
|
||||||
|
} else {
|
||||||
|
progress as i32
|
||||||
|
};
|
||||||
|
value.clamp(0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn torrent_is_playable_enough(status: &Value) -> bool {
|
||||||
|
let stat = status.get("stat").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let preload = status.get("preload").and_then(Value::as_i64).unwrap_or(0);
|
||||||
|
let loaded_size = status
|
||||||
|
.get("loaded_size")
|
||||||
|
.or_else(|| status.get("loadedSize"))
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let preload_size = status
|
||||||
|
.get("preload_size")
|
||||||
|
.or_else(|| status.get("preloadSize"))
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
stat >= 3
|
||||||
|
|| preload >= 100
|
||||||
|
|| (preload_size > 0 && loaded_size >= preload_size)
|
||||||
|
|| (preload_size <= 0 && loaded_size >= 512 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn torrent_status_key(status: &Value) -> &'static str {
|
||||||
|
match status.get("stat").and_then(Value::as_i64).unwrap_or(0) {
|
||||||
|
1 => "player.torrent_status.preloading",
|
||||||
|
2 => "player.torrent_status.downloading",
|
||||||
|
3 => "player.torrent_status.ready",
|
||||||
|
_ => "player.torrent_status.loading_metadata",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn torrent_status_info_json(status_json: &str) -> Option<String> {
|
||||||
|
let status = serde_json::from_str::<Value>(status_json).ok()?;
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"bufferProgress": torrent_buffer_progress(&status),
|
||||||
|
"isPlayableEnough": torrent_is_playable_enough(&status),
|
||||||
|
"statusKey": torrent_status_key(&status)
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalize_language(value: &str) -> String {
|
||||||
|
value.to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn normalize_language_preference(value: &str) -> String {
|
||||||
|
normalize_language(value)
|
||||||
|
.split(['-', '_'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn resolve_preferred_audio_language(
|
||||||
|
last_audio_language: Option<&str>,
|
||||||
|
preferred_audio_language: Option<&str>,
|
||||||
|
original_language: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
if let Some(memory) = last_audio_language
|
||||||
|
.map(normalize_language)
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
{
|
||||||
|
return memory;
|
||||||
|
}
|
||||||
|
let Some(preferred) = preferred_audio_language
|
||||||
|
.map(normalize_language)
|
||||||
|
.filter(|value| value != "none")
|
||||||
|
else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
if preferred != "en" {
|
||||||
|
return preferred;
|
||||||
|
}
|
||||||
|
if original_language.map(normalize_language).as_deref() == Some("ja") {
|
||||||
|
"ja".to_string()
|
||||||
|
} else {
|
||||||
|
preferred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn subtitle_language_alias_matches(label: &str, normalized_preference: &str) -> bool {
|
||||||
|
match normalized_preference {
|
||||||
|
"tr" => ["turkish", "turkce", "turk", "altyazi", "altyazı"]
|
||||||
|
.iter()
|
||||||
|
.any(|alias| label.contains(alias)),
|
||||||
|
"en" => ["english", "eng"].iter().any(|alias| label.contains(alias)),
|
||||||
|
"ja" => ["japanese", "jpn"]
|
||||||
|
.iter()
|
||||||
|
.any(|alias| label.contains(alias)),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn subtitle_language_matches(
|
||||||
|
label: &str,
|
||||||
|
language: Option<&str>,
|
||||||
|
preferred_language: &str,
|
||||||
|
) -> bool {
|
||||||
|
let normalized_preference = normalize_language_preference(preferred_language);
|
||||||
|
let word_regex =
|
||||||
|
regex::Regex::new(&format!(r"\b{}\b", regex::escape(&normalized_preference))).ok();
|
||||||
|
subtitle_language_matches_precompiled(label, language, &normalized_preference, word_regex.as_ref())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subtitle_language_matches_precompiled(
|
||||||
|
label: &str,
|
||||||
|
language: Option<&str>,
|
||||||
|
normalized_preference: &str,
|
||||||
|
word_regex: Option<®ex::Regex>,
|
||||||
|
) -> bool {
|
||||||
|
if normalized_preference.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let language = normalize_language(language.unwrap_or(""));
|
||||||
|
if language.starts_with(normalized_preference) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let label = normalize_language(label);
|
||||||
|
word_regex.is_some_and(|regex| regex.is_match(&label))
|
||||||
|
|| subtitle_language_alias_matches(&label, normalized_preference)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct SubtitleSelectionTrack {
|
||||||
|
id: Option<String>,
|
||||||
|
label: String,
|
||||||
|
language: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_preferred_subtitle_index_in_tracks(
|
||||||
|
tracks: &[SubtitleSelectionTrack],
|
||||||
|
last_subtitle_language: Option<&str>,
|
||||||
|
preferred_subtitle_language: Option<&str>,
|
||||||
|
secondary_subtitle_language: Option<&str>,
|
||||||
|
) -> i32 {
|
||||||
|
let primary = last_subtitle_language
|
||||||
|
.filter(|value| !value.is_empty() && *value != "__off__")
|
||||||
|
.or_else(|| preferred_subtitle_language.filter(|value| *value != "none"));
|
||||||
|
if let Some(preferred) = primary {
|
||||||
|
let norm = normalize_language_preference(preferred);
|
||||||
|
let word_regex =
|
||||||
|
regex::Regex::new(&format!(r"\b{}\b", regex::escape(&norm))).ok();
|
||||||
|
if let Some(index) = tracks.iter().position(|track| {
|
||||||
|
subtitle_language_matches_precompiled(&track.label, track.language.as_deref(), &norm, word_regex.as_ref())
|
||||||
|
}) {
|
||||||
|
return index as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(secondary) = secondary_subtitle_language.filter(|value| *value != "none") {
|
||||||
|
let norm = normalize_language_preference(secondary);
|
||||||
|
let word_regex =
|
||||||
|
regex::Regex::new(&format!(r"\b{}\b", regex::escape(&norm))).ok();
|
||||||
|
if let Some(index) = tracks.iter().position(|track| {
|
||||||
|
subtitle_language_matches_precompiled(&track.label, track.language.as_deref(), &norm, word_regex.as_ref())
|
||||||
|
}) {
|
||||||
|
return index as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
-1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn find_preferred_subtitle_index(
|
||||||
|
tracks_json: &str,
|
||||||
|
last_subtitle_language: Option<&str>,
|
||||||
|
preferred_subtitle_language: Option<&str>,
|
||||||
|
secondary_subtitle_language: Option<&str>,
|
||||||
|
) -> i32 {
|
||||||
|
let Ok(tracks) = serde_json::from_str::<Vec<SubtitleSelectionTrack>>(tracks_json) else {
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
find_preferred_subtitle_index_in_tracks(
|
||||||
|
&tracks,
|
||||||
|
last_subtitle_language,
|
||||||
|
preferred_subtitle_language,
|
||||||
|
secondary_subtitle_language,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct PlayerTrackStateRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
available_subtitles: Vec<SubtitleSelectionTrack>,
|
||||||
|
last_audio_language: Option<String>,
|
||||||
|
preferred_audio_language: Option<String>,
|
||||||
|
original_language: Option<String>,
|
||||||
|
last_subtitle_language: Option<String>,
|
||||||
|
preferred_subtitle_language: Option<String>,
|
||||||
|
secondary_subtitle_language: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn player_track_state_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<PlayerTrackStateRequest>(request_json).ok()?;
|
||||||
|
let preferred_audio_language = resolve_preferred_audio_language(
|
||||||
|
request
|
||||||
|
.last_audio_language
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.is_empty()),
|
||||||
|
request
|
||||||
|
.preferred_audio_language
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.is_empty()),
|
||||||
|
request
|
||||||
|
.original_language
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.is_empty()),
|
||||||
|
);
|
||||||
|
let preferred_subtitle_index = find_preferred_subtitle_index_in_tracks(
|
||||||
|
&request.available_subtitles,
|
||||||
|
request
|
||||||
|
.last_subtitle_language
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.is_empty()),
|
||||||
|
request
|
||||||
|
.preferred_subtitle_language
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.is_empty()),
|
||||||
|
request
|
||||||
|
.secondary_subtitle_language
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.is_empty()),
|
||||||
|
);
|
||||||
|
let preferred_subtitle_id = if preferred_subtitle_index >= 0 {
|
||||||
|
request
|
||||||
|
.available_subtitles
|
||||||
|
.get(preferred_subtitle_index as usize)
|
||||||
|
.and_then(|track| track.id.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"preferredAudioLanguage": preferred_audio_language,
|
||||||
|
"preferredSubtitleIndex": preferred_subtitle_index,
|
||||||
|
"preferredSubtitleId": preferred_subtitle_id,
|
||||||
|
"subtitlesDisabled": preferred_subtitle_index < 0
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub(crate) struct StreamSelectionItem {
|
||||||
|
name: Option<String>,
|
||||||
|
title: Option<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
addon_name: Option<String>,
|
||||||
|
playable_url: Option<String>,
|
||||||
|
binge_group: Option<String>,
|
||||||
|
filename: Option<String>,
|
||||||
|
effective_filename: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StreamSelectionItem {
|
||||||
|
pub(crate) fn matches_episode(&self, video_id: &str) -> bool {
|
||||||
|
stream_matches_episode(
|
||||||
|
video_id,
|
||||||
|
&[
|
||||||
|
self.title.clone().unwrap_or_default(),
|
||||||
|
self.name.clone().unwrap_or_default(),
|
||||||
|
self.description.clone().unwrap_or_default(),
|
||||||
|
self.filename.clone().unwrap_or_default(),
|
||||||
|
self.effective_filename.clone().unwrap_or_default(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_playable_for_episode(&self, video_id: &str) -> bool {
|
||||||
|
self.playable_url
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|value| !value.trim().is_empty())
|
||||||
|
&& self.matches_episode(video_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn selection_text(&self) -> String {
|
||||||
|
[
|
||||||
|
self.name.as_deref(),
|
||||||
|
self.title.as_deref(),
|
||||||
|
self.description.as_deref(),
|
||||||
|
self.addon_name.as_deref(),
|
||||||
|
self.playable_url.as_deref(),
|
||||||
|
self.binge_group.as_deref(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stream_selection_item_from_value(v: &Value) -> StreamSelectionItem {
|
||||||
|
StreamSelectionItem {
|
||||||
|
name: v.get("name").and_then(Value::as_str).map(str::to_string),
|
||||||
|
title: v.get("title").and_then(Value::as_str).map(str::to_string),
|
||||||
|
description: v.get("description").and_then(Value::as_str).map(str::to_string),
|
||||||
|
addon_name: v.get("addonName").and_then(Value::as_str).map(str::to_string),
|
||||||
|
playable_url: v.get("playableUrl").and_then(Value::as_str).map(str::to_string),
|
||||||
|
binge_group: v.get("bingeGroup").and_then(Value::as_str).map(str::to_string),
|
||||||
|
filename: v.get("filename").and_then(Value::as_str).map(str::to_string),
|
||||||
|
effective_filename: v.get("effectiveFilename").and_then(Value::as_str).map(str::to_string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn index_of_first_playable<F>(
|
||||||
|
streams: &[StreamSelectionItem],
|
||||||
|
video_id: &str,
|
||||||
|
predicate: F,
|
||||||
|
) -> Option<usize>
|
||||||
|
where
|
||||||
|
F: Fn(&StreamSelectionItem) -> bool,
|
||||||
|
{
|
||||||
|
streams
|
||||||
|
.iter()
|
||||||
|
.position(|stream| stream.is_playable_for_episode(video_id) && predicate(stream))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn manual_stream_index(
|
||||||
|
streams: &[StreamSelectionItem],
|
||||||
|
video_id: &str,
|
||||||
|
initial_stream_index: i32,
|
||||||
|
saved_url: Option<&str>,
|
||||||
|
saved_title: Option<&str>,
|
||||||
|
) -> i32 {
|
||||||
|
let matched_index = saved_url
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.and_then(|value| {
|
||||||
|
index_of_first_playable(streams, video_id, |stream| {
|
||||||
|
stream.playable_url.as_deref() == Some(value)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
saved_title
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.and_then(|value| {
|
||||||
|
index_of_first_playable(streams, video_id, |stream| {
|
||||||
|
stream.title.as_deref() == Some(value)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if let Some(index) = matched_index {
|
||||||
|
return index as i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
if initial_stream_index >= 0
|
||||||
|
&& streams
|
||||||
|
.get(initial_stream_index as usize)
|
||||||
|
.is_some_and(|stream| stream.matches_episode(video_id))
|
||||||
|
{
|
||||||
|
return initial_stream_index;
|
||||||
|
}
|
||||||
|
|
||||||
|
streams
|
||||||
|
.iter()
|
||||||
|
.position(|stream| stream.matches_episode(video_id))
|
||||||
|
.map(|index| index as i32)
|
||||||
|
.unwrap_or(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_stream_index_inner(
|
||||||
|
streams: &[StreamSelectionItem],
|
||||||
|
current_video_id: &str,
|
||||||
|
initial_stream_index: i32,
|
||||||
|
saved_url: Option<&str>,
|
||||||
|
saved_title: Option<&str>,
|
||||||
|
source_selection_mode: &str,
|
||||||
|
regex_pattern: Option<&str>,
|
||||||
|
preferred_binge_group: Option<&str>,
|
||||||
|
) -> i32 {
|
||||||
|
if streams.is_empty() {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(group) = preferred_binge_group.filter(|value| !value.trim().is_empty()) {
|
||||||
|
if let Some(index) = index_of_first_playable(streams, current_video_id, |stream| {
|
||||||
|
stream.binge_group.as_deref() == Some(group)
|
||||||
|
}) {
|
||||||
|
return index as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match source_selection_mode {
|
||||||
|
STREAM_SOURCE_MODE_REGEX => {
|
||||||
|
let Some(pattern) = regex_pattern.filter(|value| !value.trim().is_empty()) else {
|
||||||
|
return manual_stream_index(streams, current_video_id, initial_stream_index, saved_url, saved_title);
|
||||||
|
};
|
||||||
|
let regex = match regex::RegexBuilder::new(pattern)
|
||||||
|
.case_insensitive(true)
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(regex) => regex,
|
||||||
|
Err(_) => return manual_stream_index(streams, current_video_id, initial_stream_index, saved_url, saved_title),
|
||||||
|
};
|
||||||
|
if let Some(index) = index_of_first_playable(streams, current_video_id, |stream| {
|
||||||
|
regex.is_match(&stream.selection_text())
|
||||||
|
}) {
|
||||||
|
return index as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
STREAM_SOURCE_MODE_FIRST => {
|
||||||
|
if let Some(index) = index_of_first_playable(streams, current_video_id, |_| true) {
|
||||||
|
return index as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
manual_stream_index(streams, current_video_id, initial_stream_index, saved_url, saved_title)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn select_stream_index(
|
||||||
|
streams_json: &str,
|
||||||
|
current_video_id: &str,
|
||||||
|
initial_stream_index: i32,
|
||||||
|
saved_url: Option<&str>,
|
||||||
|
saved_title: Option<&str>,
|
||||||
|
source_selection_mode: &str,
|
||||||
|
regex_pattern: Option<&str>,
|
||||||
|
preferred_binge_group: Option<&str>,
|
||||||
|
) -> i32 {
|
||||||
|
let Ok(streams) = serde_json::from_str::<Vec<StreamSelectionItem>>(streams_json) else {
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
select_stream_index_inner(&streams, current_video_id, initial_stream_index, saved_url, saved_title, source_selection_mode, regex_pattern, preferred_binge_group)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn select_stream_index_values(
|
||||||
|
streams: &[Value],
|
||||||
|
current_video_id: &str,
|
||||||
|
initial_stream_index: i32,
|
||||||
|
saved_url: Option<&str>,
|
||||||
|
saved_title: Option<&str>,
|
||||||
|
source_selection_mode: &str,
|
||||||
|
regex_pattern: Option<&str>,
|
||||||
|
preferred_binge_group: Option<&str>,
|
||||||
|
) -> i32 {
|
||||||
|
let items: Vec<StreamSelectionItem> = streams.iter().map(stream_selection_item_from_value).collect();
|
||||||
|
select_stream_index_inner(&items, current_video_id, initial_stream_index, saved_url, saved_title, source_selection_mode, regex_pattern, preferred_binge_group)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
fn track_state(request: Value) -> Value {
|
||||||
|
serde_json::from_str(&player_track_state_json(&request.to_string()).unwrap()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_track_state_uses_audio_memory_before_profile_preference() {
|
||||||
|
let state = track_state(json!({
|
||||||
|
"lastAudioLanguage": "tr",
|
||||||
|
"preferredAudioLanguage": "en",
|
||||||
|
"originalLanguage": "en"
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert_eq!(state["preferredAudioLanguage"], "tr");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_track_state_uses_japanese_original_for_english_anime_preference() {
|
||||||
|
let state = track_state(json!({
|
||||||
|
"preferredAudioLanguage": "en",
|
||||||
|
"originalLanguage": "ja"
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert_eq!(state["preferredAudioLanguage"], "ja");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_track_state_selects_subtitle_memory_then_secondary() {
|
||||||
|
let memory = track_state(json!({
|
||||||
|
"availableSubtitles": [
|
||||||
|
{ "id": "en", "label": "English", "language": "en" },
|
||||||
|
{ "id": "tr", "label": "Turkish", "language": "tr" }
|
||||||
|
],
|
||||||
|
"lastSubtitleLanguage": "tr",
|
||||||
|
"preferredSubtitleLanguage": "en"
|
||||||
|
}));
|
||||||
|
assert_eq!(memory["preferredSubtitleIndex"], 1);
|
||||||
|
assert_eq!(memory["preferredSubtitleId"], "tr");
|
||||||
|
assert_eq!(memory["subtitlesDisabled"], false);
|
||||||
|
|
||||||
|
let secondary = track_state(json!({
|
||||||
|
"availableSubtitles": [
|
||||||
|
{ "id": "tr", "label": "Turkish", "language": "tr" }
|
||||||
|
],
|
||||||
|
"preferredSubtitleLanguage": "en",
|
||||||
|
"secondarySubtitleLanguage": "tr"
|
||||||
|
}));
|
||||||
|
assert_eq!(secondary["preferredSubtitleIndex"], 0);
|
||||||
|
assert_eq!(secondary["preferredSubtitleId"], "tr");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_track_state_disables_subtitles_when_no_preferred_match_exists() {
|
||||||
|
let state = track_state(json!({
|
||||||
|
"availableSubtitles": [
|
||||||
|
{ "id": "tr", "label": "Turkish", "language": "tr" }
|
||||||
|
],
|
||||||
|
"preferredSubtitleLanguage": "en"
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert_eq!(state["preferredSubtitleIndex"], -1);
|
||||||
|
assert!(state["preferredSubtitleId"].is_null());
|
||||||
|
assert_eq!(state["subtitlesDisabled"], true);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/tmdb_plan.rs
Normal file
102
src/tmdb_plan.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
pub(crate) fn tmdb_content_type(content_type: &str) -> &str {
|
||||||
|
if content_type == "series" { "tv" } else { "movie" }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn tmdb_language(language: &str) -> String {
|
||||||
|
match language {
|
||||||
|
"" | "en" => "en-US".to_string(),
|
||||||
|
"tr" => "tr-TR".to_string(),
|
||||||
|
lang if lang.contains('-') => lang.to_string(),
|
||||||
|
lang => format!("{}-{}", lang, lang.to_uppercase()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn tmdb_image_url(path: Option<&str>, size: &str) -> Option<String> {
|
||||||
|
let path = path?.trim();
|
||||||
|
if path.is_empty() { return None; }
|
||||||
|
Some(format!("https://image.tmdb.org/t/p/{size}{path}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn tmdb_meta_to_meta_json(item_json: &str, requested_type: &str, language: &str) -> Option<String> {
|
||||||
|
let item: Value = serde_json::from_str(item_json).ok()?;
|
||||||
|
let id = item.get("id").and_then(Value::as_i64)?;
|
||||||
|
let media_type = item.get("media_type").and_then(Value::as_str).unwrap_or("");
|
||||||
|
let has_tv = media_type == "tv" || item.get("first_air_date").is_some();
|
||||||
|
let content_type = if requested_type == "series" || has_tv { "series" } else { "movie" };
|
||||||
|
let name = item.get("title")
|
||||||
|
.or_else(|| item.get("name"))
|
||||||
|
.or_else(|| item.get("original_name"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or(if language == "tr" { "Bilinmeyen" } else { "Unknown" });
|
||||||
|
let released = item.get("release_date").or_else(|| item.get("first_air_date"))
|
||||||
|
.and_then(Value::as_str);
|
||||||
|
let poster = tmdb_image_url(item.get("poster_path").and_then(Value::as_str), "w500");
|
||||||
|
let background = tmdb_image_url(item.get("backdrop_path").and_then(Value::as_str), "original");
|
||||||
|
Some(serde_json::to_string(&json!({
|
||||||
|
"id": format!("tmdb:{id}"),
|
||||||
|
"type": content_type,
|
||||||
|
"name": name,
|
||||||
|
"poster": poster,
|
||||||
|
"background": background,
|
||||||
|
"releaseInfo": released.map(|r| &r[..4.min(r.len())]),
|
||||||
|
})).ok()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn tmdb_video_to_trailer_json(video_json: &str) -> Option<String> {
|
||||||
|
let video: Value = serde_json::from_str(video_json).ok()?;
|
||||||
|
let site = video.get("site").and_then(Value::as_str).unwrap_or("").to_lowercase();
|
||||||
|
if site != "youtube" { return None; }
|
||||||
|
let key = video.get("key").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty())?;
|
||||||
|
let video_type = video.get("type").and_then(Value::as_str).map(str::trim).unwrap_or("Trailer");
|
||||||
|
let type_lower = video_type.to_lowercase();
|
||||||
|
if !["trailer", "teaser", "clip"].contains(&type_lower.as_str()) { return None; }
|
||||||
|
let title = video.get("name").and_then(Value::as_str).map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty()).unwrap_or(video_type);
|
||||||
|
Some(serde_json::to_string(&json!({
|
||||||
|
"url": format!("https://www.youtube.com/watch?v={key}"),
|
||||||
|
"title": title,
|
||||||
|
"type": video_type,
|
||||||
|
})).ok()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn tmdb_bulk_metas_to_metas_json(
|
||||||
|
items_json: &str,
|
||||||
|
requested_type: &str,
|
||||||
|
language: &str,
|
||||||
|
) -> Option<String> {
|
||||||
|
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
|
||||||
|
let metas: Vec<Value> = items.iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
let s = serde_json::to_string(item).ok()?;
|
||||||
|
let meta_json = tmdb_meta_to_meta_json(&s, requested_type, language)?;
|
||||||
|
serde_json::from_str(&meta_json).ok()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
serde_json::to_string(&metas).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn tmdb_bulk_videos_to_trailers_json(items_json: &str) -> Option<String> {
|
||||||
|
let items: Vec<Value> = serde_json::from_str(items_json).ok()?;
|
||||||
|
let trailers: Vec<Value> = items.iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
let s = serde_json::to_string(item).ok()?;
|
||||||
|
let json = tmdb_video_to_trailer_json(&s)?;
|
||||||
|
serde_json::from_str(&json).ok()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
serde_json::to_string(&trailers).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns (numeric_tmdb_id, already_resolved) — if already_resolved is true
|
||||||
|
/// the caller can use the id directly without an extra API call.
|
||||||
|
pub(crate) fn tmdb_resolve_id_hint(content_id: &str) -> (String, bool) {
|
||||||
|
let base = content_id.replace("tmdb:", "");
|
||||||
|
let base = base.split(':').next().unwrap_or(&base);
|
||||||
|
if base.chars().all(|c| c.is_ascii_digit()) && !base.is_empty() {
|
||||||
|
return (base.to_string(), true);
|
||||||
|
}
|
||||||
|
let imdb_part = content_id.split(':').next().unwrap_or(content_id);
|
||||||
|
(imdb_part.to_string(), false)
|
||||||
|
}
|
||||||
87
src/types/addon/mod.rs
Normal file
87
src/types/addon/mod.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AddonManifest {
|
||||||
|
pub id: String,
|
||||||
|
pub version: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub transport_url: String,
|
||||||
|
pub resources: Vec<ResourceDeclaration>,
|
||||||
|
pub types: Vec<String>,
|
||||||
|
pub id_prefixes: Option<Vec<String>>,
|
||||||
|
pub catalogs: Vec<CatalogDeclaration>,
|
||||||
|
pub addon_catalogs: Option<Vec<CatalogDeclaration>>,
|
||||||
|
pub config: Option<Vec<ConfigDeclaration>>,
|
||||||
|
pub background: Option<String>,
|
||||||
|
pub logo: Option<String>,
|
||||||
|
pub contact_email: Option<String>,
|
||||||
|
pub behavior_hints: Option<BehaviorHints>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum ResourceDeclaration {
|
||||||
|
Name(String),
|
||||||
|
Object {
|
||||||
|
name: String,
|
||||||
|
types: Option<Vec<String>>,
|
||||||
|
id_prefixes: Option<Vec<String>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CatalogDeclaration {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub type_: String,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub extra: Option<Vec<ExtraDeclaration>>,
|
||||||
|
pub extra_supported: Option<Vec<String>>,
|
||||||
|
pub extra_required: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ExtraDeclaration {
|
||||||
|
pub name: String,
|
||||||
|
pub is_required: Option<bool>,
|
||||||
|
pub options: Option<Vec<String>>,
|
||||||
|
pub options_limit: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ConfigDeclaration {
|
||||||
|
pub key: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub type_: String,
|
||||||
|
pub default: Option<Value>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub options: Option<Vec<String>>,
|
||||||
|
pub required: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BehaviorHints {
|
||||||
|
pub adult: Option<bool>,
|
||||||
|
pub p2p: Option<bool>,
|
||||||
|
pub configurable: Option<bool>,
|
||||||
|
pub configuration_required: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ResourceRef {
|
||||||
|
pub resource: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub type_: String,
|
||||||
|
pub id: String,
|
||||||
|
pub extra: Option<HashMap<String, String>>,
|
||||||
|
}
|
||||||
5
src/types/mod.rs
Normal file
5
src/types/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
pub mod addon;
|
||||||
|
pub mod resource;
|
||||||
|
|
||||||
|
pub use addon::{AddonManifest, ResourceRef};
|
||||||
|
pub use resource::{MetaItem, Stream};
|
||||||
127
src/types/resource/mod.rs
Normal file
127
src/types/resource/mod.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MetaItem {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub type_: String,
|
||||||
|
pub name: String,
|
||||||
|
pub poster: Option<String>,
|
||||||
|
pub poster_shape: Option<String>,
|
||||||
|
pub background: Option<String>,
|
||||||
|
pub logo: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub release_info: Option<String>,
|
||||||
|
pub imdb_rating: Option<String>,
|
||||||
|
pub released: Option<String>,
|
||||||
|
pub genres: Option<Vec<String>>,
|
||||||
|
pub director: Option<Vec<String>>,
|
||||||
|
pub cast: Option<Vec<String>>,
|
||||||
|
pub year: Option<String>,
|
||||||
|
pub runtime: Option<String>,
|
||||||
|
pub language: Option<String>,
|
||||||
|
pub country: Option<String>,
|
||||||
|
pub awards: Option<String>,
|
||||||
|
pub website: Option<String>,
|
||||||
|
pub trailers: Option<Vec<serde_json::Value>>,
|
||||||
|
pub videos: Option<Vec<Video>>,
|
||||||
|
pub links: Option<Vec<Link>>,
|
||||||
|
pub behavior_hints: Option<MetaBehaviorHints>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Video {
|
||||||
|
pub id: String,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub released: Option<String>,
|
||||||
|
pub season: Option<i32>,
|
||||||
|
pub episode: Option<i32>,
|
||||||
|
pub thumbnail: Option<String>,
|
||||||
|
pub streams: Option<Vec<Stream>>,
|
||||||
|
pub available: Option<bool>,
|
||||||
|
pub trailer: Option<String>,
|
||||||
|
pub trailers: Option<Vec<Stream>>,
|
||||||
|
pub overview: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Stream {
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub yt_id: Option<String>,
|
||||||
|
pub info_hash: Option<String>,
|
||||||
|
pub file_idx: Option<i32>,
|
||||||
|
pub file_must_include: Option<String>,
|
||||||
|
pub nzb_url: Option<String>,
|
||||||
|
pub servers: Option<Vec<String>>,
|
||||||
|
pub rar_urls: Option<Vec<SourceObject>>,
|
||||||
|
pub zip_urls: Option<Vec<SourceObject>>,
|
||||||
|
#[serde(rename = "7zipUrls")]
|
||||||
|
pub seven_zip_urls: Option<Vec<SourceObject>>,
|
||||||
|
pub tgz_urls: Option<Vec<SourceObject>>,
|
||||||
|
pub tar_urls: Option<Vec<SourceObject>>,
|
||||||
|
pub external_url: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub sources: Option<Vec<String>>,
|
||||||
|
#[serde(rename = "subtitles")]
|
||||||
|
pub subtitles: Option<Vec<SubtitleTrack>>,
|
||||||
|
pub subtitle_tracks: Option<Vec<SubtitleTrack>>,
|
||||||
|
pub headers: Option<HashMap<String, String>>,
|
||||||
|
pub behavior_hints: Option<StreamBehaviorHints>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SourceObject {
|
||||||
|
pub url: String,
|
||||||
|
pub bytes: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SubtitleTrack {
|
||||||
|
pub id: String,
|
||||||
|
pub url: String,
|
||||||
|
pub lang: String,
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Link {
|
||||||
|
pub name: String,
|
||||||
|
pub category: String,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MetaBehaviorHints {
|
||||||
|
pub default_video_id: Option<String>,
|
||||||
|
pub featured_video_id: Option<String>,
|
||||||
|
pub has_scheduled_videos: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct StreamBehaviorHints {
|
||||||
|
pub country_whitelist: Option<Vec<String>>,
|
||||||
|
pub not_web_ready: Option<bool>,
|
||||||
|
pub video_hash: Option<String>,
|
||||||
|
pub video_size: Option<i64>,
|
||||||
|
pub filename: Option<String>,
|
||||||
|
pub binge_group: Option<String>,
|
||||||
|
pub proxy_headers: Option<ProxyHeaders>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ProxyHeaders {
|
||||||
|
pub request: Option<HashMap<String, String>>,
|
||||||
|
pub response: Option<HashMap<String, String>>,
|
||||||
|
}
|
||||||
277
src/watchlist_plan.rs
Normal file
277
src/watchlist_plan.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct TogglePlanRequest {
|
||||||
|
item: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
is_currently_in_watchlist: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
profile_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ExternalMergeRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
local_items: Vec<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
external_items: Vec<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct CollectionImportRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
collections: Vec<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct OfflineGroupingRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
items: Vec<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ProgressMergeRequest {
|
||||||
|
existing: Value,
|
||||||
|
incoming: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn watchlist_toggle_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<TogglePlanRequest>(request_json).ok()?;
|
||||||
|
let is_in_watchlist = request.is_currently_in_watchlist;
|
||||||
|
let should_add = !is_in_watchlist;
|
||||||
|
let item_id = request
|
||||||
|
.item
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"command": if should_add { "add" } else { "remove" },
|
||||||
|
"itemId": item_id,
|
||||||
|
"optimisticIsInWatchlist": should_add,
|
||||||
|
"profileId": request.profile_id
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn library_external_merge_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<ExternalMergeRequest>(request_json).ok()?;
|
||||||
|
let local_ids: std::collections::HashSet<String> = request
|
||||||
|
.local_items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item.get("id").and_then(Value::as_str).map(ToString::to_string))
|
||||||
|
.collect();
|
||||||
|
let merged_external: Vec<&Value> = request
|
||||||
|
.external_items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| {
|
||||||
|
item.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|id| !local_ids.contains(id))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut merged: Vec<Value> = request.local_items.clone();
|
||||||
|
merged.extend(merged_external.into_iter().cloned());
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"merged": merged,
|
||||||
|
"localCount": request.local_items.len(),
|
||||||
|
"externalOnlyCount": merged.len() - request.local_items.len()
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn library_collection_import_validation_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<CollectionImportRequest>(request_json).ok()?;
|
||||||
|
let mut issues = Vec::<String>::new();
|
||||||
|
let mut valid_collections = Vec::<Value>::new();
|
||||||
|
for (i, col) in request.collections.iter().enumerate() {
|
||||||
|
let id = col.get("id").and_then(Value::as_str).unwrap_or("").trim();
|
||||||
|
let title = col.get("title").and_then(Value::as_str).unwrap_or("").trim();
|
||||||
|
if id.is_empty() {
|
||||||
|
issues.push(format!("collection[{}]: missing id", i));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if title.is_empty() {
|
||||||
|
issues.push(format!("collection[{}]: missing title", i));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
valid_collections.push(col.clone());
|
||||||
|
}
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"isValid": issues.is_empty(),
|
||||||
|
"validCollections": valid_collections,
|
||||||
|
"issues": issues
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn library_offline_grouping_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<OfflineGroupingRequest>(request_json).ok()?;
|
||||||
|
let mut ready = Vec::<&Value>::new();
|
||||||
|
let mut downloading = Vec::<&Value>::new();
|
||||||
|
let mut queued = Vec::<&Value>::new();
|
||||||
|
let mut failed = Vec::<&Value>::new();
|
||||||
|
for item in &request.items {
|
||||||
|
match item.get("status").and_then(Value::as_str).unwrap_or("") {
|
||||||
|
"ready" | "complete" => ready.push(item),
|
||||||
|
"downloading" | "in_progress" => downloading.push(item),
|
||||||
|
"failed" | "error" => failed.push(item),
|
||||||
|
_ => queued.push(item),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"ready": ready,
|
||||||
|
"downloading": downloading,
|
||||||
|
"queued": queued,
|
||||||
|
"failed": failed
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn playback_progress_merge_plan_json(request_json: &str) -> Option<String> {
|
||||||
|
let request = serde_json::from_str::<ProgressMergeRequest>(request_json).ok()?;
|
||||||
|
let existing = &request.existing;
|
||||||
|
let incoming = &request.incoming;
|
||||||
|
|
||||||
|
let existing_video_id = existing.get("lastVideoId").and_then(Value::as_str);
|
||||||
|
let incoming_video_id = incoming.get("lastVideoId").and_then(Value::as_str);
|
||||||
|
let video_changed =
|
||||||
|
incoming_video_id.is_some() && incoming_video_id != existing_video_id;
|
||||||
|
|
||||||
|
let resolve_field = |key: &str| -> Value {
|
||||||
|
incoming
|
||||||
|
.get(key)
|
||||||
|
.filter(|v| !v.is_null())
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| existing.get(key).cloned())
|
||||||
|
.unwrap_or(Value::Null)
|
||||||
|
};
|
||||||
|
|
||||||
|
let last_episode_name = if video_changed {
|
||||||
|
incoming.get("lastEpisodeName").cloned().unwrap_or(Value::Null)
|
||||||
|
} else {
|
||||||
|
resolve_field("lastEpisodeName")
|
||||||
|
};
|
||||||
|
let resolve_episode_field = |key: &str| -> Value {
|
||||||
|
if video_changed {
|
||||||
|
incoming.get(key).cloned().unwrap_or(Value::Null)
|
||||||
|
} else {
|
||||||
|
resolve_field(key)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"lastVideoId": resolve_field("lastVideoId"),
|
||||||
|
"timeOffset": incoming.get("timeOffset").cloned().unwrap_or(Value::Null),
|
||||||
|
"duration": incoming.get("duration").cloned().unwrap_or(Value::Null),
|
||||||
|
"lastStreamIndex": resolve_field("lastStreamIndex"),
|
||||||
|
"lastEpisodeName": last_episode_name,
|
||||||
|
"lastEpisodeSeason": resolve_episode_field("lastEpisodeSeason"),
|
||||||
|
"lastEpisodeNumber": resolve_episode_field("lastEpisodeNumber"),
|
||||||
|
"lastEpisodeThumbnail": resolve_episode_field("lastEpisodeThumbnail"),
|
||||||
|
"lastStreamUrl": resolve_field("lastStreamUrl"),
|
||||||
|
"lastStreamTitle": resolve_field("lastStreamTitle"),
|
||||||
|
"continueWatchingPoster": resolve_field("continueWatchingPoster"),
|
||||||
|
"continueWatchingBackground": resolve_field("continueWatchingBackground"),
|
||||||
|
"lastAudioLanguage": resolve_field("lastAudioLanguage"),
|
||||||
|
"lastSubtitleLanguage": resolve_field("lastSubtitleLanguage"),
|
||||||
|
"videoChanged": video_changed
|
||||||
|
}))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_plan_adds_when_not_in_watchlist() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&watchlist_toggle_plan_json(
|
||||||
|
r#"{"item":{"id":"tt1","type":"movie"},"isCurrentlyInWatchlist":false}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result["command"], "add");
|
||||||
|
assert_eq!(result["optimisticIsInWatchlist"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_plan_removes_when_in_watchlist() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&watchlist_toggle_plan_json(
|
||||||
|
r#"{"item":{"id":"tt1","type":"movie"},"isCurrentlyInWatchlist":true}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result["command"], "remove");
|
||||||
|
assert_eq!(result["optimisticIsInWatchlist"], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_merge_deduplicates_preferring_local() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&library_external_merge_plan_json(
|
||||||
|
r#"{"localItems":[{"id":"tt1","source":"local"}],"externalItems":[{"id":"tt1","source":"external"},{"id":"tt2","source":"external"}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let merged = result["merged"].as_array().unwrap();
|
||||||
|
assert_eq!(merged.len(), 2);
|
||||||
|
assert_eq!(merged[0]["source"], "local");
|
||||||
|
assert_eq!(merged[1]["source"], "external");
|
||||||
|
assert_eq!(merged[1]["id"], "tt2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collection_import_validation_rejects_missing_id() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&library_collection_import_validation_json(
|
||||||
|
r#"{"collections":[{"title":"My List"},{"id":"c1","title":"Valid"}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result["isValid"], false);
|
||||||
|
assert_eq!(result["validCollections"].as_array().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn offline_grouping_partitions_by_status() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&library_offline_grouping_json(
|
||||||
|
r#"{"items":[{"id":"a","status":"ready"},{"id":"b","status":"downloading"},{"id":"c","status":"failed"},{"id":"d"}]}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result["ready"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(result["downloading"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(result["failed"].as_array().unwrap().len(), 1);
|
||||||
|
assert_eq!(result["queued"].as_array().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_merge_preserves_existing_fields_when_incoming_is_null() {
|
||||||
|
let result: Value = serde_json::from_str(
|
||||||
|
&playback_progress_merge_plan_json(
|
||||||
|
r#"{"existing":{"lastStreamUrl":"http://old","lastVideoId":"v1","timeOffset":1000},"incoming":{"lastVideoId":"v1","timeOffset":2000,"duration":5000}}"#,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result["timeOffset"], 2000);
|
||||||
|
assert_eq!(result["lastStreamUrl"], "http://old");
|
||||||
|
assert_eq!(result["videoChanged"], false);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
uniffi-bindgen.rs
Normal file
3
uniffi-bindgen.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
uniffi::uniffi_bindgen_main()
|
||||||
|
}
|
||||||
5
uniffi.toml
Normal file
5
uniffi.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
[bindings.kotlin]
|
||||||
|
package_name = "com.fluxa.core.uniffi"
|
||||||
|
android = true
|
||||||
|
cdylib_name = "fluxa_core"
|
||||||
|
kotlin_target_version = "2.3.0"
|
||||||
Loading…
Reference in a new issue