mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-07-26 11:52: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