Initial commit

This commit is contained in:
KhooLy 2026-06-09 14:24:41 +03:00
commit 504422871f
67 changed files with 22503 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/target/
CLAUDE.md
*.pdb
stremio-addon-api/

1485
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

37
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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)
);
}
}

View 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
View 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

File diff suppressed because it is too large Load diff

3
src/bindings/mod.rs Normal file
View file

@ -0,0 +1,3 @@
#[cfg(feature = "native")]
pub mod jni;
pub mod uniffi;

72
src/bindings/uniffi.rs Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

1168
src/core_api.rs Normal file

File diff suppressed because it is too large Load diff

194
src/core_contract.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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);
}
}

View 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");
}
}

View 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
View 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![]
}

View 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![]
}
}

View 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>,
}

View 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![]
}

View 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![]
}

View 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
View 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![]
}

View 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

File diff suppressed because it is too large Load diff

View 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![]
}

View 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![]
}

View 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![]
}

View 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![]
}

View 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![]
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

95
src/player_scrobble.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
},
}

View 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
View 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
View 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
View 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
View 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<&regex::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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
fn main() {
uniffi::uniffi_bindgen_main()
}

5
uniffi.toml Normal file
View 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"