chore: format core sources
Some checks failed
CI / test (push) Has been cancelled
CI / clippy (push) Has been cancelled
CI / bench-check (push) Has been cancelled
CI / wasm-check (push) Has been cancelled
CI / fmt (push) Has been cancelled

This commit is contained in:
KhooLy 2026-07-22 20:36:55 +03:00
parent 4e787268b8
commit 1fab5d27c2
28 changed files with 714 additions and 253 deletions

View file

@ -113,7 +113,9 @@ fn parse_seek_entry(
let (id, _, content_start, content_end) = read_element(buf, pos, end)?;
match id {
ID_SEEK_ID => seek_id = Some(read_uint_be(&buf[content_start..content_end])),
ID_SEEK_POSITION => seek_position = Some(read_uint_be(&buf[content_start..content_end])),
ID_SEEK_POSITION => {
seek_position = Some(read_uint_be(&buf[content_start..content_end]))
}
_ => {}
}
pos = content_end.max(pos + 1);
@ -135,9 +137,13 @@ fn scan_seek_head(
while pos < end {
let (id, _, content_start, content_end) = read_element(buf, pos, end)?;
if id == ID_SEEK {
if let Some(offset) =
parse_seek_entry(buf, content_start, content_end, segment_content_start, target_id)
{
if let Some(offset) = parse_seek_entry(
buf,
content_start,
content_end,
segment_content_start,
target_id,
) {
return Some(offset);
}
}
@ -168,8 +174,13 @@ fn scan_segment(buf: &[u8], mut pos: usize, end: usize) -> MkvChapterScan {
};
}
if id == ID_SEEK_HEAD {
chapters_offset_hint =
scan_seek_head(buf, content_start, content_end, segment_content_start, ID_CHAPTERS);
chapters_offset_hint = scan_seek_head(
buf,
content_start,
content_end,
segment_content_start,
ID_CHAPTERS,
);
}
pos = content_end.max(pos + 1);
}
@ -221,7 +232,9 @@ pub(crate) fn parse_mkv_chapters_at_offset_json(buf: &[u8]) -> String {
let end = buf.len();
let chapters = read_element(buf, 0, end)
.filter(|(id, ..)| *id == ID_CHAPTERS)
.map(|(_, _, content_start, content_end)| parse_chapters_element(buf, content_start, content_end))
.map(|(_, _, content_start, content_end)| {
parse_chapters_element(buf, content_start, content_end)
})
.unwrap_or_default();
let arr: Vec<serde_json::Value> = chapters
.iter()

View file

@ -2,9 +2,9 @@ use dolby_vision::rpu::dovi_rpu::DoviRpu;
use dolby_vision::rpu::extension_metadata::blocks::ExtMetadataBlock;
use serde::Deserialize;
mod stats;
mod hls_urls;
mod dvcc;
mod hls_urls;
mod stats;
// Startup self-test
//
@ -1337,8 +1337,11 @@ mod mkv;
pub(crate) use mkv::*;
#[cfg(test)]
mod tests {
use super::dvcc::{
apply_patch_at_offset as apply_dvcc_patch_at_offset, mangle_fourcc as mangle_dvcc_fourcc,
parse_content_range_start,
};
use super::*;
use super::dvcc::{apply_patch_at_offset as apply_dvcc_patch_at_offset, mangle_fourcc as mangle_dvcc_fourcc, parse_content_range_start};
// DVCC fourcc mangler
#[test]

View file

@ -15,7 +15,11 @@ pub(super) fn mangle_fourcc(data: &mut [u8]) -> usize {
count
}
pub(crate) fn apply_patch_at_offset(data: &mut [u8], file_offset: u64, scan_window: usize) -> usize {
pub(crate) fn apply_patch_at_offset(
data: &mut [u8],
file_offset: u64,
scan_window: usize,
) -> usize {
if file_offset >= scan_window as u64 {
return 0;
}
@ -38,7 +42,9 @@ pub(super) struct ContainerInfo {
impl ContainerInfo {
pub(super) fn not_has_hdr10_fallback(self) -> bool {
self.profile == 4 || (self.profile == 5 && self.compat_id != 1) || (self.profile == 10 && matches!(self.compat_id, 0 | 2 | 3))
self.profile == 4
|| (self.profile == 5 && self.compat_id != 1)
|| (self.profile == 10 && matches!(self.compat_id, 0 | 2 | 3))
}
}
@ -47,7 +53,10 @@ pub(super) fn scan_info(data: &[u8]) -> Option<ContainerInfo> {
if data[index..index + 4] == *b"dvcC" {
let payload = &data[index + 4..];
if payload.len() >= 5 {
return Some(ContainerInfo { profile: (payload[2] >> 1) & 0x7F, compat_id: (payload[4] >> 4) & 0x0F });
return Some(ContainerInfo {
profile: (payload[2] >> 1) & 0x7F,
compat_id: (payload[4] >> 4) & 0x0F,
});
}
}
}

View file

@ -17,17 +17,21 @@ pub(super) fn fetch(
}
pub(super) fn rewrite_manifest(manifest: &str, base_url: &str, proxy_segment_base: &str) -> String {
manifest.lines().map(|line| {
if line.is_empty() {
return line.to_string();
}
if line.starts_with('#') {
rewrite_uri_attributes(&rewrite_p7_codecs(line), base_url, proxy_segment_base)
} else {
let absolute_url = resolve_url(base_url, line);
format!("{}{}", proxy_segment_base, percent_encode(&absolute_url))
}
}).collect::<Vec<_>>().join("\n")
manifest
.lines()
.map(|line| {
if line.is_empty() {
return line.to_string();
}
if line.starts_with('#') {
rewrite_uri_attributes(&rewrite_p7_codecs(line), base_url, proxy_segment_base)
} else {
let absolute_url = resolve_url(base_url, line);
format!("{}{}", proxy_segment_base, percent_encode(&absolute_url))
}
})
.collect::<Vec<_>>()
.join("\n")
}
pub(super) fn percent_decode(value: &str) -> String {
@ -113,10 +117,17 @@ fn resolve_url(base_url: &str, relative: &str) -> String {
if let Some(scheme_end) = base_url.find("://") {
let remainder = &base_url[scheme_end + 3..];
let authority_end = remainder.find('/').unwrap_or(remainder.len());
return format!("{}{}", &base_url[..scheme_end + 3 + authority_end], relative);
return format!(
"{}{}",
&base_url[..scheme_end + 3 + authority_end],
relative
);
}
}
let base_directory = base_url.rfind('/').map(|position| &base_url[..position + 1]).unwrap_or(base_url);
let base_directory = base_url
.rfind('/')
.map(|position| &base_url[..position + 1])
.unwrap_or(base_url);
normalize_url_path(format!("{}{}", base_directory, relative))
}
@ -131,19 +142,48 @@ fn normalize_url_path(url: String) -> String {
let mut parts = Vec::new();
for segment in path.split('/') {
match segment {
".." => { parts.pop(); }
".." => {
parts.pop();
}
"." | "" => {}
value => parts.push(value),
}
}
format!("{}/{}{}", prefix, parts.join("/"), if path.ends_with('/') { "/" } else { "" })
format!(
"{}/{}{}",
prefix,
parts.join("/"),
if path.ends_with('/') { "/" } else { "" }
)
}
fn percent_encode(value: &str) -> String {
let mut output = String::with_capacity(value.len() * 3);
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b':' | b'/' | b'?' | b'#' | b'@' | b'!' | b'$' | b'&' | b'\'' | b'(' | b')' | b'*' | b'+' | b',' | b';' | b'=' => output.push(byte as char),
b'A'..=b'Z'
| b'a'..=b'z'
| b'0'..=b'9'
| b'-'
| b'_'
| b'.'
| b'~'
| b':'
| b'/'
| b'?'
| b'#'
| b'@'
| b'!'
| b'$'
| b'&'
| b'\''
| b'('
| b')'
| b'*'
| b'+'
| b','
| b';'
| b'=' => output.push(byte as char),
value => output.push_str(&format!("%{value:02X}")),
}
}

View file

@ -1,6 +1,9 @@
use super::*;
pub(super) fn parse_range(value: Option<&HeaderValue>, length: u64) -> Result<Option<(u64, u64)>, ()> {
pub(super) fn parse_range(
value: Option<&HeaderValue>,
length: u64,
) -> Result<Option<(u64, u64)>, ()> {
let Some(value) = value else {
return Ok(None);
};

View file

@ -472,7 +472,8 @@ pub(crate) fn parse_catalogs(json: &Value) -> Vec<Value> {
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let extra_supported = string_array(&Value::Object(map.clone()), "extraSupported");
let extra_supported =
string_array(&Value::Object(map.clone()), "extraSupported");
let supports_initial_load = !extras.iter().any(|extra| {
extra
.get("isRequired")
@ -1041,7 +1042,10 @@ mod tests {
manifest["catalogs"][0]["supportsInitialLoad"].as_bool(),
Some(true)
);
assert_eq!(manifest["catalogs"][0]["supportsSearch"].as_bool(), Some(true));
assert_eq!(
manifest["catalogs"][0]["supportsSearch"].as_bool(),
Some(true)
);
assert_eq!(
manifest["catalogs"][0]["hasRequiredExtraExceptGenre"].as_bool(),
Some(false)

View file

@ -262,19 +262,28 @@ mod tests {
#[test]
fn tracking_gate_ignores_movies() {
let meta: Value = serde_json::from_str(r#"{"id":"tt1","name":"Anime Movie","type":"movie","genres":["anime"]}"#).unwrap();
let meta: Value = serde_json::from_str(
r#"{"id":"tt1","name":"Anime Movie","type":"movie","genres":["anime"]}"#,
)
.unwrap();
assert!(!should_attempt_anime_tracking(&meta));
}
#[test]
fn tracking_gate_accepts_anime_genre_series() {
let meta: Value = serde_json::from_str(r#"{"id":"tt1","name":"Some Show","type":"series","genres":["Anime"]}"#).unwrap();
let meta: Value = serde_json::from_str(
r#"{"id":"tt1","name":"Some Show","type":"series","genres":["Anime"]}"#,
)
.unwrap();
assert!(should_attempt_anime_tracking(&meta));
}
#[test]
fn tracking_gate_rejects_unrelated_series() {
let meta: Value = serde_json::from_str(r#"{"id":"tt1","name":"Some Drama","type":"series","genres":["Drama"]}"#).unwrap();
let meta: Value = serde_json::from_str(
r#"{"id":"tt1","name":"Some Drama","type":"series","genres":["Drama"]}"#,
)
.unwrap();
assert!(!should_attempt_anime_tracking(&meta));
}

View file

@ -207,4 +207,3 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_drain
}))
.unwrap_or(ptr::null_mut())
}

View file

@ -40,8 +40,8 @@ fn classify_chapter(title: &str) -> Option<&'static str> {
match normalized.as_str() {
"op" | "opening" | "intro" | "introduction" | "op sequence" | "mixed-intro"
| "opening sequence" | "opening theme" => return Some("intro"),
"ed" | "ending" | "outro" | "credits" | "end credits" | "closing"
| "ending theme" | "ending sequence" => return Some("outro"),
"ed" | "ending" | "outro" | "credits" | "end credits" | "closing" | "ending theme"
| "ending sequence" => return Some("outro"),
"recap" | "previously" | "previously on" | "cold open" => return Some("recap"),
_ => {}
}

View file

@ -40,7 +40,12 @@ pub(crate) fn simkl_history_request_json(args_json: &str) -> Option<String> {
.iter()
.filter_map(|(season, episodes)| {
let season = season.parse::<i64>().ok()?;
let episodes = episodes.as_array()?.iter().filter_map(Value::as_i64).map(|number| json!({ "number": number })).collect::<Vec<_>>();
let episodes = episodes
.as_array()?
.iter()
.filter_map(Value::as_i64)
.map(|number| json!({ "number": number }))
.collect::<Vec<_>>();
Some(json!({ "number": season, "episodes": episodes }))
})
.collect::<Vec<_>>()
@ -553,7 +558,10 @@ pub(crate) fn trakt_scrobble_url(action: &str) -> Option<String> {
}
pub(crate) fn trakt_playback_url(content_type: Option<&str>) -> Option<String> {
let suffix = match content_type.map(str::trim).filter(|value| !value.is_empty()) {
let suffix = match content_type
.map(str::trim)
.filter(|value| !value.is_empty())
{
None => None,
Some("movie" | "movies") => Some("movies"),
Some("series" | "show" | "shows" | "episode" | "episodes") => Some("episodes"),
@ -780,10 +788,16 @@ pub(super) fn trakt_id_from_source(source: &Value) -> Option<String> {
pub(crate) fn trakt_playback_delete_ids_json(args_json: &str) -> Option<String> {
let args: Value = serde_json::from_str(args_json).ok()?;
let content_id = args.get("contentId")?.as_str()?;
let ids = args.get("items")?.as_array()?.iter().filter_map(|item| {
let source = item.get("show").or_else(|| item.get("movie"))?;
(trakt_id_from_source(source).as_deref() == Some(content_id)).then(|| item.get("id")?.as_i64())
}).collect::<Vec<_>>();
let ids = args
.get("items")?
.as_array()?
.iter()
.filter_map(|item| {
let source = item.get("show").or_else(|| item.get("movie"))?;
(trakt_id_from_source(source).as_deref() == Some(content_id))
.then(|| item.get("id")?.as_i64())
})
.collect::<Vec<_>>();
serde_json::to_string(&ids).ok()
}
@ -1179,28 +1193,18 @@ pub(crate) fn merge_continue_watching_lists_json(
mod provider_mappers;
pub(crate) use provider_mappers::{
replace_external_continue_watching_json,
simkl_lookup_id_for_type,
simkl_mark_watched_body_json,
simkl_match_episode_json,
simkl_recommendation_candidates_json,
simkl_recommendation_to_meta_json,
simkl_watched_to_ids_json,
simkl_watching_to_items_json,
simkl_watchlist_body_json,
simkl_watchlist_to_items_json,
trakt_mark_watched_body_json,
trakt_playback_items_dedup_json,
trakt_related_items_to_metas_json,
trakt_related_lookup_slug,
replace_external_continue_watching_json, simkl_lookup_id_for_type,
simkl_mark_watched_body_json, simkl_match_episode_json, simkl_recommendation_candidates_json,
simkl_recommendation_to_meta_json, simkl_watched_to_ids_json, simkl_watching_to_items_json,
simkl_watchlist_body_json, simkl_watchlist_to_items_json, trakt_mark_watched_body_json,
trakt_playback_items_dedup_json, trakt_related_items_to_metas_json, trakt_related_lookup_slug,
};
mod anilist;
pub(crate) use anilist::{
anilist_entries_to_sync, anilist_media_list_status,
anilist_save_media_list_entry_variables_json, anilist_search_best_match_json,
extract_anilist_id_from_links,
merge_library_items_by_id,
extract_anilist_id_from_links, merge_library_items_by_id,
};
#[cfg(test)]
mod tests {
@ -1215,7 +1219,13 @@ mod tests {
{"id": "tt2", "reason": "Nuvio", "timeOffset": 100, "duration": 1000, "savedAt": "2026-07-18T22:15:23Z"},
{"id": "tt3", "reason": "Nuvio", "timeOffset": 100, "duration": 1000, "savedAt": "2026-07-17T19:11:51Z"},
]);
let result = replace_external_continue_watching_json("[]", Some("Nuvio"), &items.to_string(), None, None);
let result = replace_external_continue_watching_json(
"[]",
Some("Nuvio"),
&items.to_string(),
None,
None,
);
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
let ids: Vec<&str> = parsed.iter().map(|v| v["id"].as_str().unwrap()).collect();
assert_eq!(ids, vec!["tt2", "tt3", "tt1"]);
@ -1434,15 +1444,14 @@ mod tests {
.unwrap();
assert_eq!(trakt_actual, trakt_expected);
let simkl_input = include_str!("../tests/fixtures/external_sync/simkl_mark_watched_input.json");
let simkl_input =
include_str!("../tests/fixtures/external_sync/simkl_mark_watched_input.json");
let simkl_expected: Value = serde_json::from_str(include_str!(
"../tests/fixtures/external_sync/simkl_mark_watched_expected.json"
))
.unwrap();
let simkl_actual: Value = serde_json::from_str(
&simkl_mark_watched_body_json(simkl_input).unwrap(),
)
.unwrap();
let simkl_actual: Value =
serde_json::from_str(&simkl_mark_watched_body_json(simkl_input).unwrap()).unwrap();
assert_eq!(simkl_actual, simkl_expected);
let trakt_playback_expected: Value = serde_json::from_str(include_str!(
@ -1670,9 +1679,18 @@ mod tests {
#[test]
fn mal_sync_policy_maps_auth_and_episode_updates() {
assert_eq!(external_sync_response_action("mal", 401), "refresh_credentials");
assert_eq!(external_sync_response_action("simkl", 401), "clear_credentials");
assert_eq!(external_sync_refresh_retry_action(Some(401)), "clear_credentials");
assert_eq!(
external_sync_response_action("mal", 401),
"refresh_credentials"
);
assert_eq!(
external_sync_response_action("simkl", 401),
"clear_credentials"
);
assert_eq!(
external_sync_refresh_retry_action(Some(401)),
"clear_credentials"
);
let watched = mal_list_update_json(
&json!({
"meta": { "id": "mal:42", "type": "series", "episodesCount": 12 },
@ -1699,7 +1717,12 @@ mod tests {
.and_then(|value| serde_json::from_str::<Value>(&value).ok())
.unwrap();
assert_eq!(history["shows"][0]["seasons"][0]["number"], 2);
assert_eq!(history["shows"][0]["seasons"][0]["episodes"].as_array().map(Vec::len), Some(2));
assert_eq!(
history["shows"][0]["seasons"][0]["episodes"]
.as_array()
.map(Vec::len),
Some(2)
);
let removal = simkl_watchlist_request_json(
&json!({ "imdbId": "tt1", "isSeries": false }).to_string(),

View file

@ -193,19 +193,21 @@ pub(crate) fn anilist_search_best_match_json(args_json: &str) -> Option<String>
if search_name.is_empty() {
return None;
}
let year = meta
.get("year")
.and_then(Value::as_i64)
.or_else(|| meta.get("releaseInfo").and_then(Value::as_str).and_then(parse_year_from_text));
let year = meta.get("year").and_then(Value::as_i64).or_else(|| {
meta.get("releaseInfo")
.and_then(Value::as_str)
.and_then(parse_year_from_text)
});
let normalized_name = normalize_anime_title(search_name);
let name_matches = |item: &Value| -> bool {
item.get("title")
.and_then(Value::as_object)
.is_some_and(|title| {
title
.values()
.any(|t| t.as_str().is_some_and(|s| normalize_anime_title(s) == normalized_name))
title.values().any(|t| {
t.as_str()
.is_some_and(|s| normalize_anime_title(s) == normalized_name)
})
})
};
let year_ok = |item: &Value| -> bool {
@ -224,7 +226,10 @@ pub(crate) fn anilist_search_best_match_json(args_json: &str) -> Option<String>
serde_json::to_string(&json!({ "anilistId": id, "confidence": "title-year" })).ok()
}
pub(crate) fn anilist_media_list_status(total_episodes: i64, progress_episode: i64) -> &'static str {
pub(crate) fn anilist_media_list_status(
total_episodes: i64,
progress_episode: i64,
) -> &'static str {
if total_episodes > 0 && progress_episode >= total_episodes {
"COMPLETED"
} else {
@ -383,4 +388,3 @@ pub(crate) fn merge_library_items_by_id(local: &[Value], incoming: &[Value]) ->
let merged: Vec<Value> = order.iter().filter_map(|id| by_id.remove(id)).collect();
Value::Array(merged)
}

View file

@ -1,8 +1,8 @@
use serde_json::{json, Value};
mod engine_routes;
mod addon_resource_routes;
mod addon_support_routes;
mod engine_routes;
use addon_resource_routes::route_addon_resource;
use addon_support_routes::{route_addon_uptime, route_trailer_subtitles};
use engine_routes::route_engine_lifecycle;
@ -10,12 +10,12 @@ use engine_routes::route_engine_lifecycle;
#[cfg(feature = "native")]
use crate::dolby_vision_rpu;
use crate::{
addon_protocol, addon_resource, addon_store, addon_uptime, anime_detection, app_state, calendar_plan,
content_identity, core_contract, data_policy, desktop_playback, discovery_plan, external_sync,
headless_adapter_plan, headless_engine, home_ranking, intro_segments, library_state,
nuvio_sync, offline_download, platform_plan, player_flow, player_policy, player_scrobble,
plugins, profile_avatar_pack, profile_contract, profile_prefs, repository_flow, search_plan,
stream_policy, tmdb_plan, trailer_subtitles, watchlist_plan,
addon_protocol, addon_resource, addon_store, addon_uptime, anime_detection, app_state,
calendar_plan, content_identity, core_contract, data_policy, desktop_playback, discovery_plan,
external_sync, headless_adapter_plan, headless_engine, home_ranking, intro_segments,
library_state, nuvio_sync, offline_download, platform_plan, player_flow, player_policy,
player_scrobble, plugins, profile_avatar_pack, profile_contract, profile_prefs,
repository_flow, search_plan, stream_policy, tmdb_plan, trailer_subtitles, watchlist_plan,
};
#[derive(Clone, Copy, PartialEq, Eq)]
@ -588,10 +588,18 @@ fn route_offline(method: &str, args_json: &str) -> Outcome {
fn route_content_identity(method: &str, args_json: &str) -> Outcome {
match method {
"contentImdbId" => Ok(json!(content_identity::imdb_id(&arg_str(args_json, "id")?))),
"contentBaseId" => Ok(Value::String(content_identity::base_content_id(&arg_str(args_json, "id")?))),
"normalizeSeriesLookupId" => Ok(Value::String(content_identity::normalize_series_lookup_id(&arg_str(args_json, "id")?))),
"isTmdbLikeContentId" => Ok(json!(content_identity::is_tmdb_like_content_id(&arg_str(args_json, "id")?))),
"tmdbNumericId" => Ok(json!(content_identity::tmdb_numeric_id(&arg_str(args_json, "id")?))),
"contentBaseId" => Ok(Value::String(content_identity::base_content_id(&arg_str(
args_json, "id",
)?))),
"normalizeSeriesLookupId" => Ok(Value::String(
content_identity::normalize_series_lookup_id(&arg_str(args_json, "id")?),
)),
"isTmdbLikeContentId" => Ok(json!(content_identity::is_tmdb_like_content_id(&arg_str(
args_json, "id"
)?))),
"tmdbNumericId" => Ok(json!(content_identity::tmdb_numeric_id(&arg_str(
args_json, "id"
)?))),
"parseVideoId" => into_json(content_identity::parse_video_id_json(&arg_str(
args_json, "id",
)?)),
@ -910,9 +918,9 @@ fn route_anime_detection(method: &str, args_json: &str) -> Outcome {
))
}
// args_json IS the meta object
"shouldAttemptAnimeTracking" => Ok(json!(
anime_detection::should_attempt_anime_tracking(&object(args_json)?)
)),
"shouldAttemptAnimeTracking" => Ok(json!(anime_detection::should_attempt_anime_tracking(
&object(args_json)?
))),
_ => Err(fail(
ErrorKind::UnknownMethod,
@ -939,10 +947,14 @@ fn route_nuvio_sync(method: &str, args_json: &str) -> Outcome {
opt_json(nuvio_sync::sort_addons_by_priority_json(args_json))
}
"nuvioAddonState" => opt_json(nuvio_sync::addon_state_json(args_json)),
"nuvioAddonReconciliationPlan" => opt_json(nuvio_sync::addon_reconciliation_plan_json(args_json)),
"nuvioAddonReconciliationPlan" => {
opt_json(nuvio_sync::addon_reconciliation_plan_json(args_json))
}
"nuvioLibraryItemRequest" => opt_json(nuvio_sync::library_item_request_json(args_json)),
"nuvioWatchedItemsRequest" => opt_json(nuvio_sync::watched_items_request_json(args_json)),
"nuvioPlaybackProgressRequest" => opt_json(nuvio_sync::playback_progress_request_json(args_json)),
"nuvioPlaybackProgressRequest" => {
opt_json(nuvio_sync::playback_progress_request_json(args_json))
}
"nuvioCollectionRequest" => opt_json(nuvio_sync::collection_request_json(args_json)),
_ => Err(fail(
@ -1202,9 +1214,9 @@ fn route_profile_avatar_pack(method: &str, args_json: &str) -> Outcome {
"profileAvatarPackDiscoveryPlan" => {
opt_json(profile_avatar_pack::profile_avatar_pack_discovery_plan_json(args_json))
}
"profileAvatarPackCatalog" => {
opt_json(profile_avatar_pack::profile_avatar_pack_catalog_json(args_json))
}
"profileAvatarPackCatalog" => opt_json(
profile_avatar_pack::profile_avatar_pack_catalog_json(args_json),
),
"profileAvatarPackParse" => {
opt_json(profile_avatar_pack::profile_avatar_pack_json(args_json))
}

View file

@ -5,21 +5,48 @@ pub(super) fn route_addon_resource(method: &str, args_json: &str) -> Outcome {
"parseAddonResourceResult" => {
let args = object(args_json)?;
let body = args.get("body").and_then(Value::as_str).map(str::to_string);
let status_code = field(&args, "statusCode")?.as_i64().ok_or_else(|| fail(ErrorKind::InvalidArgs, "statusCode must be a number"))? as i32;
into_json(addon_resource::parse_addon_resource_result_json(field_str(&args, "resource")?, field_str(&args, "url")?, status_code, body.as_deref()))
let status_code = field(&args, "statusCode")?
.as_i64()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "statusCode must be a number"))?
as i32;
into_json(addon_resource::parse_addon_resource_result_json(
field_str(&args, "resource")?,
field_str(&args, "url")?,
status_code,
body.as_deref(),
))
}
"parseAddonStreamResult" => {
let args = object(args_json)?;
let body = args.get("body").and_then(Value::as_str).map(str::to_string);
let status_code = field(&args, "statusCode")?.as_i64().ok_or_else(|| fail(ErrorKind::InvalidArgs, "statusCode must be a number"))? as i32;
into_json(addon_resource::parse_addon_stream_result_json(field_str(&args, "url")?, status_code, body.as_deref(), field_str(&args, "addonName")?))
let status_code = field(&args, "statusCode")?
.as_i64()
.ok_or_else(|| fail(ErrorKind::InvalidArgs, "statusCode must be a number"))?
as i32;
into_json(addon_resource::parse_addon_stream_result_json(
field_str(&args, "url")?,
status_code,
body.as_deref(),
field_str(&args, "addonName")?,
))
}
"normalizeAddonSubtitles" => {
let args = object(args_json)?;
into_json(addon_resource::normalize_addon_subtitles_json(field_str(&args, "subtitles")?, field_str(&args, "resourceUrl")?))
into_json(addon_resource::normalize_addon_subtitles_json(
field_str(&args, "subtitles")?,
field_str(&args, "resourceUrl")?,
))
}
"parseCatalogItems" => opt_json(addon_resource::parse_catalog_items_json(&arg_str(args_json, "body")?, &arg_str(args_json, "fallbackType")?)),
"parseDirectStreams" => opt_json(addon_resource::parse_direct_streams_json(&arg_str(args_json, "body")?)),
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
"parseCatalogItems" => opt_json(addon_resource::parse_catalog_items_json(
&arg_str(args_json, "body")?,
&arg_str(args_json, "fallbackType")?,
)),
"parseDirectStreams" => opt_json(addon_resource::parse_direct_streams_json(&arg_str(
args_json, "body",
)?)),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}

View file

@ -3,15 +3,27 @@ use super::*;
pub(super) fn route_addon_uptime(method: &str, args_json: &str) -> Outcome {
match method {
"addonUptimeMatchPlan" => opt_json(addon_uptime::addon_uptime_match_plan_json(args_json)),
_ => Err(fail(ErrorKind::UnknownMethod, "unknown addon uptime method")),
_ => Err(fail(
ErrorKind::UnknownMethod,
"unknown addon uptime method",
)),
}
}
pub(super) fn route_trailer_subtitles(method: &str, args_json: &str) -> Outcome {
match method {
"trailerSubtitleSelectionPlan" => opt_json(trailer_subtitles::trailer_subtitle_selection_plan_json(args_json)),
"normalizeTrailerSubtitleUrl" => opt_json(trailer_subtitles::normalize_trailer_subtitle_url_json(args_json)),
"parseTrailerSubtitleCues" => opt_json(trailer_subtitles::parse_trailer_subtitle_cues_json(args_json)),
_ => Err(fail(ErrorKind::UnknownMethod, "unknown trailer subtitle method")),
"trailerSubtitleSelectionPlan" => opt_json(
trailer_subtitles::trailer_subtitle_selection_plan_json(args_json),
),
"normalizeTrailerSubtitleUrl" => opt_json(
trailer_subtitles::normalize_trailer_subtitle_url_json(args_json),
),
"parseTrailerSubtitleCues" => opt_json(
trailer_subtitles::parse_trailer_subtitle_cues_json(args_json),
),
_ => Err(fail(
ErrorKind::UnknownMethod,
"unknown trailer subtitle method",
)),
}
}

View file

@ -2,25 +2,53 @@ use super::*;
pub(super) fn route_engine_lifecycle(method: &str, args_json: &str) -> Outcome {
match method {
"engine.create" => Ok(json!(headless_engine::create_headless_engine(args_json) as i64)),
"engine.snapshot" => result_json(headless_engine::headless_engine_snapshot_json(handle(args_json)?), method),
"engine.create" => Ok(json!(
headless_engine::create_headless_engine(args_json) as i64
)),
"engine.snapshot" => result_json(
headless_engine::headless_engine_snapshot_json(handle(args_json)?),
method,
),
"engine.dispatch" => {
let args = object(args_json)?;
result_json(headless_engine::headless_engine_dispatch_json(field_u64(&args, "handle")?, &field(&args, "action")?.to_string()), method)
result_json(
headless_engine::headless_engine_dispatch_json(
field_u64(&args, "handle")?,
&field(&args, "action")?.to_string(),
),
method,
)
}
"engine.completeEffect" => {
let args = object(args_json)?;
result_json(headless_engine::headless_engine_complete_effect_json(field_u64(&args, "handle")?, &field(&args, "result")?.to_string()), method)
result_json(
headless_engine::headless_engine_complete_effect_json(
field_u64(&args, "handle")?,
&field(&args, "result")?.to_string(),
),
method,
)
}
"engine.destroy" => Ok(json!(headless_engine::destroy_headless_engine(handle(args_json)?))),
"engine.destroy" => Ok(json!(headless_engine::destroy_headless_engine(handle(
args_json
)?))),
"core.drainErrorLog" => opt_json(Some(crate::log_sink::drain_core_log_json())),
"app.create" => Ok(json!(app_state::create_app_core_state(args_json) as i64)),
"app.state" => result_json(app_state::app_core_state_json(handle(args_json)?), method),
"app.dispatch" => {
let args = object(args_json)?;
result_json(app_state::app_core_dispatch_json(field_u64(&args, "handle")?, &field(&args, "action")?.to_string()), method)
result_json(
app_state::app_core_dispatch_json(
field_u64(&args, "handle")?,
&field(&args, "action")?.to_string(),
),
method,
)
}
"app.destroy" => Ok(json!(app_state::destroy_app_core_state(handle(args_json)?))),
_ => Err(fail(ErrorKind::UnknownMethod, format!("no such method `{method}`"))),
_ => Err(fail(
ErrorKind::UnknownMethod,
format!("no such method `{method}`"),
)),
}
}

View file

@ -64,7 +64,9 @@ pub(super) fn route_external_sync_trakt(method: &str, args_json: &str) -> Outcom
&arg_str(args_json, "idsJson")?,
)),
"traktSyncItemToMeta" => opt_json(external_sync::trakt_sync_item_to_meta_json(args_json)),
"traktPlaybackDeleteIds" => opt_json(external_sync::trakt_playback_delete_ids_json(args_json)),
"traktPlaybackDeleteIds" => {
opt_json(external_sync::trakt_playback_delete_ids_json(args_json))
}
"traktIdsFromContentId" => opt_json(external_sync::trakt_ids_from_content_id_json(
&arg_str(args_json, "rawId")?,
)),
@ -222,8 +224,12 @@ pub(super) fn route_external_sync_trakt(method: &str, args_json: &str) -> Outcom
pub(super) fn route_external_sync_simkl(method: &str, args_json: &str) -> Outcome {
match method {
"simklHistoryRequest" => opt_json(external_sync::simkl_history_request_json(args_json)),
"simklWatchlistRequest" => opt_json(external_sync::simkl_watchlist_request_json(args_json, false)),
"simklWatchlistRemovalRequest" => opt_json(external_sync::simkl_watchlist_request_json(args_json, true)),
"simklWatchlistRequest" => opt_json(external_sync::simkl_watchlist_request_json(
args_json, false,
)),
"simklWatchlistRemovalRequest" => {
opt_json(external_sync::simkl_watchlist_request_json(args_json, true))
}
"simklMarkWatchedBody" => opt_json(external_sync::simkl_mark_watched_body_json(args_json)),
"simklWatchlistBody" => opt_json(external_sync::simkl_watchlist_body_json(args_json)),
"simklWatchingToItems" => {

View file

@ -53,12 +53,13 @@ pub(super) fn route_library_state(method: &str, args_json: &str) -> Outcome {
}
"isEpisodeReleased" => {
let args = object(args_json)?;
let video: Value = serde_json::from_str(field_str(&args, "videoJson")?).map_err(|e| {
fail(
ErrorKind::InvalidArgs,
format!("videoJson is not valid JSON: {e}"),
)
})?;
let video: Value =
serde_json::from_str(field_str(&args, "videoJson")?).map_err(|e| {
fail(
ErrorKind::InvalidArgs,
format!("videoJson is not valid JSON: {e}"),
)
})?;
let now_ms = field_u64(&args, "nowMs")? as i64;
Ok(json!(library_state::is_episode_released(&video, now_ms)))
}
@ -204,12 +205,24 @@ pub(super) fn route_library_state(method: &str, args_json: &str) -> Outcome {
"folderPageState" => opt_json(home_ranking::folder_page_state_json(args_json)),
"folderSourcePagePlan" => opt_json(home_ranking::folder_source_page_plan_json(args_json)),
"homeHeroPlan" => opt_json(home_ranking::home_hero_plan_json(args_json)),
"homeBillboardCandidateScore" => Ok(json!(home_ranking::billboard_candidate_score_json(args_json))),
"homeBillboardVisualScore" => Ok(json!(home_ranking::billboard_visual_score_json(args_json))),
"homeBillboardHasBackdrop" => Ok(json!(home_ranking::billboard_has_backdrop_json(args_json))),
"homeBillboardEditorialMatchScore" => Ok(json!(home_ranking::billboard_editorial_match_score_json(args_json))),
"homeBillboardIdentityKey" => Ok(json!(home_ranking::billboard_identity_key_json(args_json))),
"homeBillboardNormalizedTitle" => Ok(Value::String(home_ranking::billboard_normalized_title(&arg_str(args_json, "value")?))),
"homeBillboardCandidateScore" => Ok(json!(home_ranking::billboard_candidate_score_json(
args_json
))),
"homeBillboardVisualScore" => {
Ok(json!(home_ranking::billboard_visual_score_json(args_json)))
}
"homeBillboardHasBackdrop" => {
Ok(json!(home_ranking::billboard_has_backdrop_json(args_json)))
}
"homeBillboardEditorialMatchScore" => Ok(json!(
home_ranking::billboard_editorial_match_score_json(args_json)
)),
"homeBillboardIdentityKey" => {
Ok(json!(home_ranking::billboard_identity_key_json(args_json)))
}
"homeBillboardNormalizedTitle" => Ok(Value::String(
home_ranking::billboard_normalized_title(&arg_str(args_json, "value")?),
)),
"mergeFolderSources" => opt_json(home_ranking::merge_folder_sources_json(args_json)),
"watchedMapDiff" => {
let args = object(args_json)?;

View file

@ -254,38 +254,108 @@ pub(crate) fn home_bootstrap_preparation_plan_json(request_json: &str) -> Option
let profile = request.get("profile").cloned().unwrap_or_else(|| json!({}));
let prefs = request.get("prefs").cloned().unwrap_or_else(|| json!({}));
let library = request.get("library").cloned().unwrap_or_else(|| json!({}));
let disabled = profile.pointer("/addonSettings/disabledLocalAddons").or_else(|| profile.get("disabledLocalAddons"))
.and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).collect::<HashSet<_>>();
let mut addons = request.get("addons").and_then(Value::as_array).cloned().unwrap_or_default().into_iter().filter(|addon| {
let key = addon.get("transportUrl").and_then(Value::as_str).or_else(|| addon.pointer("/manifest/id").and_then(Value::as_str)).unwrap_or("");
!disabled.contains(key)
}).collect::<Vec<_>>();
if let Some(builtin) = request.get("builtinAddon").filter(|value| value.is_object()) {
if prefs.get("tmdbPreferOverAddons").and_then(Value::as_bool).unwrap_or(false) { addons.insert(0, builtin.clone()); } else { addons.push(builtin.clone()); }
let disabled = profile
.pointer("/addonSettings/disabledLocalAddons")
.or_else(|| profile.get("disabledLocalAddons"))
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect::<HashSet<_>>();
let mut addons = request
.get("addons")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
.filter(|addon| {
let key = addon
.get("transportUrl")
.and_then(Value::as_str)
.or_else(|| addon.pointer("/manifest/id").and_then(Value::as_str))
.unwrap_or("");
!disabled.contains(key)
})
.collect::<Vec<_>>();
if let Some(builtin) = request
.get("builtinAddon")
.filter(|value| value.is_object())
{
if prefs
.get("tmdbPreferOverAddons")
.and_then(Value::as_bool)
.unwrap_or(false)
{
addons.insert(0, builtin.clone());
} else {
addons.push(builtin.clone());
}
}
let local = library.get("continueWatching").and_then(Value::as_array).cloned().unwrap_or_default();
let external = library.get("externalContinueWatching").and_then(Value::as_array).cloned().unwrap_or_default();
let progress = library.get("progress").cloned().unwrap_or_else(|| json!({}));
let local = library
.get("continueWatching")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let external = library
.get("externalContinueWatching")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let progress = library
.get("progress")
.cloned()
.unwrap_or_else(|| json!({}));
let continue_watching: Value = crate::external_sync::merge_continue_watching_lists_json(
&Value::Array(local).to_string(), &Value::Array(external).to_string(), &progress.to_string(),
prefs.get("syncCwSourceOfTruth").and_then(Value::as_str), prefs.get("syncCwRanking").and_then(Value::as_str)
).and_then(|value| serde_json::from_str(&value).ok()).unwrap_or_else(|| json!([]));
&Value::Array(local).to_string(),
&Value::Array(external).to_string(),
&progress.to_string(),
prefs.get("syncCwSourceOfTruth").and_then(Value::as_str),
prefs.get("syncCwRanking").and_then(Value::as_str),
)
.and_then(|value| serde_json::from_str(&value).ok())
.unwrap_or_else(|| json!([]));
let addons_json = Value::Array(addons.clone()).to_string();
let mut feeds: Vec<Value> = crate::search_plan::build_metadata_feed_options_json(&addons_json)
.and_then(|value| serde_json::from_str(&value).ok()).unwrap_or_default();
.and_then(|value| serde_json::from_str(&value).ok())
.unwrap_or_default();
for feed in &mut feeds {
if let Some(genre) = crate::search_plan::resolve_feed_option_genre_json(&feed.to_string(), &addons_json)
.and_then(|value| serde_json::from_str(&value).ok()) { feed["genre"] = genre; }
if let Some(genre) =
crate::search_plan::resolve_feed_option_genre_json(&feed.to_string(), &addons_json)
.and_then(|value| serde_json::from_str(&value).ok())
{
feed["genre"] = genre;
}
}
let available = feeds.iter().filter_map(|feed| feed.get("key").and_then(Value::as_str)).map(str::to_string).collect::<Vec<_>>();
let available = feeds
.iter()
.filter_map(|feed| feed.get("key").and_then(Value::as_str))
.map(str::to_string)
.collect::<Vec<_>>();
let selected = prefs.get("homeFeedToggles").and_then(Value::as_array);
let effective = if selected.is_some_and(|values| !values.is_empty()) {
crate::content_identity::effective_metadata_feed_selection_json(&Value::Array(selected.cloned().unwrap_or_default()).to_string(), &Value::Array(available.iter().map(|value| json!(value)).collect()).to_string())
.and_then(|value| serde_json::from_str::<Vec<String>>(&value).ok()).unwrap_or_else(|| available.clone())
} else { available };
let visible_feeds = feeds.iter().filter(|feed| feed.get("key").and_then(Value::as_str).is_some_and(|key| effective.iter().any(|value| value == key))).cloned().collect::<Vec<_>>();
crate::content_identity::effective_metadata_feed_selection_json(
&Value::Array(selected.cloned().unwrap_or_default()).to_string(),
&Value::Array(available.iter().map(|value| json!(value)).collect()).to_string(),
)
.and_then(|value| serde_json::from_str::<Vec<String>>(&value).ok())
.unwrap_or_else(|| available.clone())
} else {
available
};
let visible_feeds = feeds
.iter()
.filter(|feed| {
feed.get("key")
.and_then(Value::as_str)
.is_some_and(|key| effective.iter().any(|value| value == key))
})
.cloned()
.collect::<Vec<_>>();
let shelves: Value = build_home_collection_shelves_json(&profile.to_string(), &addons_json)
.and_then(|value| serde_json::from_str(&value).ok()).unwrap_or_else(|| json!({"pinnedShelves": [], "regularShelves": [], "hiddenFolderCategories": []}));
.and_then(|value| serde_json::from_str(&value).ok())
.unwrap_or_else(
|| json!({"pinnedShelves": [], "regularShelves": [], "hiddenFolderCategories": []}),
);
serde_json::to_string(&json!({"addons": addons, "continueWatching": continue_watching, "metadataFeeds": feeds, "visibleFeeds": visible_feeds, "shelves": shelves, "feedConcurrency": 6})).ok()
}
@ -303,12 +373,38 @@ pub(crate) fn home_bootstrap_completion_plan_json(request_json: &str) -> Option<
"addonName": label.split(" - ").next().unwrap_or(label), "transportUrl": feed.get("transportUrl"), "catalogId": feed.get("id")
}))
}).collect::<Vec<_>>();
let shelves = preparation.get("shelves").cloned().unwrap_or_else(|| json!({}));
let mut all = shelves.get("pinnedShelves").and_then(Value::as_array).cloned().unwrap_or_default();
let shelves = preparation
.get("shelves")
.cloned()
.unwrap_or_else(|| json!({}));
let mut all = shelves
.get("pinnedShelves")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
all.extend(categories.iter().cloned());
all.extend(shelves.get("regularShelves").and_then(Value::as_array).into_iter().flatten().cloned());
all.extend(shelves.get("hiddenFolderCategories").and_then(Value::as_array).into_iter().flatten().cloned());
let billboard = categories.first().and_then(|category| category.get("items")).and_then(Value::as_array).and_then(|items| items.first()).cloned();
all.extend(
shelves
.get("regularShelves")
.and_then(Value::as_array)
.into_iter()
.flatten()
.cloned(),
);
all.extend(
shelves
.get("hiddenFolderCategories")
.and_then(Value::as_array)
.into_iter()
.flatten()
.cloned(),
);
let billboard = categories
.first()
.and_then(|category| category.get("items"))
.and_then(Value::as_array)
.and_then(|items| items.first())
.cloned();
serde_json::to_string(&json!({"categories": all, "continueWatching": preparation.get("continueWatching"), "metadataFeeds": preparation.get("metadataFeeds"), "billboard": billboard})).ok()
}
@ -1427,10 +1523,21 @@ mod tests {
});
let args = json!({ "meta": meta, "daysSinceRelease": 10 });
assert_eq!(billboard_candidate_score_json(&args.to_string()), Some(2127));
assert_eq!(
billboard_candidate_score_json(&args.to_string()),
Some(2127)
);
assert_eq!(billboard_visual_score_json(&args.to_string()), Some(470));
assert_eq!(billboard_editorial_match_score_json(&json!({ "meta": args["meta"], "minYear": 2020 }).to_string()), Some(738));
assert_eq!(billboard_identity_key_json(&args.to_string()), Some("series:tt1".to_string()));
assert_eq!(
billboard_editorial_match_score_json(
&json!({ "meta": args["meta"], "minYear": 2020 }).to_string()
),
Some(738)
);
assert_eq!(
billboard_identity_key_json(&args.to_string()),
Some("series:tt1".to_string())
);
assert_eq!(billboard_normalized_title("Çığ Şöw"), "cig sow");
}

View file

@ -22,12 +22,12 @@ mod action_contract;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod addon_protocol;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod addon_uptime;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod addon_resource;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod addon_store;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod addon_uptime;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod anime_detection;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod app_state;
@ -82,15 +82,15 @@ mod player_flow;
mod player_policy;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod player_scrobble;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod plugins;
#[cfg(feature = "plugin-js-engine")]
pub mod plugin_runtime;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod profile_contract;
mod plugins;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod profile_avatar_pack;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod profile_contract;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod profile_prefs;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod repository_flow;

View file

@ -40,7 +40,11 @@ pub(crate) fn addon_state_json(args_json: &str) -> Option<String> {
let mut installed_urls = Vec::new();
let mut disabled_urls = Vec::new();
for addon in addons {
let Some(url) = addon.get("url").and_then(Value::as_str).filter(|url| !url.is_empty()) else {
let Some(url) = addon
.get("url")
.and_then(Value::as_str)
.filter(|url| !url.is_empty())
else {
continue;
};
if !installed_urls.iter().any(|item| item == url) {
@ -146,15 +150,27 @@ pub(crate) fn watched_items_request_json(args_json: &str) -> Option<String> {
let args: Value = serde_json::from_str(args_json).ok()?;
let meta = args.get("meta")?;
let at = args.get("watchedAt").and_then(Value::as_i64)?;
if meta.get("type").and_then(Value::as_str) == Some("movie") { return serde_json::to_string(&json!([{ "content_id": meta.get("id"), "content_type": "movie", "title": meta.get("name"), "watched_at": at }])).ok(); }
if meta.get("type").and_then(Value::as_str) == Some("movie") {
return serde_json::to_string(&json!([{ "content_id": meta.get("id"), "content_type": "movie", "title": meta.get("name"), "watched_at": at }])).ok();
}
let items = args.get("episodes").and_then(Value::as_array)?.iter().filter_map(|e| Some(json!({"content_id": meta.get("id"), "content_type": meta.get("type"), "title": meta.get("name"), "season": e.get("season")?.as_i64()?, "episode": e.get("number")?.as_i64()?, "watched_at": at}))).collect::<Vec<_>>();
serde_json::to_string(&items).ok()
}
pub(crate) fn playback_progress_request_json(args_json: &str) -> Option<String> {
let args: Value = serde_json::from_str(args_json).ok()?; let meta = args.get("meta")?;
let video = args.get("videoId").and_then(Value::as_str).unwrap_or_else(|| meta.get("id").and_then(Value::as_str).unwrap_or(""));
let parts: Vec<&str> = video.split(':').collect(); let season = (parts.len() == 3).then(|| parts[1].parse::<i64>().ok()).flatten(); let episode = (parts.len() == 3).then(|| parts[2].parse::<i64>().ok()).flatten();
let args: Value = serde_json::from_str(args_json).ok()?;
let meta = args.get("meta")?;
let video = args
.get("videoId")
.and_then(Value::as_str)
.unwrap_or_else(|| meta.get("id").and_then(Value::as_str).unwrap_or(""));
let parts: Vec<&str> = video.split(':').collect();
let season = (parts.len() == 3)
.then(|| parts[1].parse::<i64>().ok())
.flatten();
let episode = (parts.len() == 3)
.then(|| parts[2].parse::<i64>().ok())
.flatten();
serde_json::to_string(&json!({"content_id": meta.get("id"), "content_type": meta.get("type"), "video_id": video, "position": args.get("position"), "duration": args.get("duration"), "last_watched": args.get("watchedAt"), "season": season, "episode": episode, "progress_key": if let (Some(s), Some(e)) = (season, episode) { format!("{}_s{s}e{e}", meta.get("id").and_then(Value::as_str).unwrap_or("")) } else { meta.get("id").and_then(Value::as_str).unwrap_or("").to_string() }})).ok()
}
@ -172,25 +188,44 @@ pub(crate) fn addon_reconciliation_plan_json(args_json: &str) -> Option<String>
let current = args.get("current")?.as_array()?;
let desired = args.get("desired")?.as_array()?;
let user_id = args.get("userId").and_then(Value::as_str).unwrap_or("");
let profile_id = args.get("profileId").and_then(Value::as_i64).unwrap_or_default();
let profile_id = args
.get("profileId")
.and_then(Value::as_i64)
.unwrap_or_default();
let desired_by_url: std::collections::BTreeMap<String, Value> = desired.iter().enumerate().filter_map(|(index, addon)| {
let url = addon.get("url")?.as_str()?.trim();
(!url.is_empty()).then(|| (url.to_string(), json!({"url":url,"name":addon.get("name").and_then(Value::as_str),"enabled":addon.get("enabled").and_then(Value::as_bool).unwrap_or(true),"sort_order":addon.get("sort_order").and_then(Value::as_i64).unwrap_or(index as i64)})))
}).collect();
let delete_ids = current.iter().filter_map(|addon| {
let url = addon.get("url")?.as_str()?;
(!desired_by_url.contains_key(url)).then(|| addon.get("id").cloned()).flatten()
}).collect::<Vec<_>>();
let updates = current.iter().filter_map(|addon| {
let url = addon.get("url")?.as_str()?;
Some(json!({"id":addon.get("id")?,"payload":desired_by_url.get(url)?}))
}).collect::<Vec<_>>();
let creates = desired_by_url.iter().filter(|(url, _)| !current.iter().any(|addon| addon.get("url").and_then(Value::as_str) == Some(url.as_str()))).map(|(_, payload)| {
let mut payload = payload.as_object().cloned().unwrap_or_default();
payload.insert("user_id".into(), Value::String(user_id.to_string()));
payload.insert("profile_id".into(), json!(profile_id));
Value::Object(payload)
}).collect::<Vec<_>>();
let delete_ids = current
.iter()
.filter_map(|addon| {
let url = addon.get("url")?.as_str()?;
(!desired_by_url.contains_key(url))
.then(|| addon.get("id").cloned())
.flatten()
})
.collect::<Vec<_>>();
let updates = current
.iter()
.filter_map(|addon| {
let url = addon.get("url")?.as_str()?;
Some(json!({"id":addon.get("id")?,"payload":desired_by_url.get(url)?}))
})
.collect::<Vec<_>>();
let creates = desired_by_url
.iter()
.filter(|(url, _)| {
!current
.iter()
.any(|addon| addon.get("url").and_then(Value::as_str) == Some(url.as_str()))
})
.map(|(_, payload)| {
let mut payload = payload.as_object().cloned().unwrap_or_default();
payload.insert("user_id".into(), Value::String(user_id.to_string()));
payload.insert("profile_id".into(), json!(profile_id));
Value::Object(payload)
})
.collect::<Vec<_>>();
serde_json::to_string(&json!({"deleteIds":delete_ids,"updates":updates,"creates":creates})).ok()
}

View file

@ -123,7 +123,12 @@ fn cbc_decrypt(key: &[u8], iv: &[u8], data: &[u8], no_padding: bool) -> Result<V
}
}
fn cbc_encrypt_with<C>(key: &[u8], iv: &[u8], data: &[u8], no_padding: bool) -> Result<Vec<u8>, String>
fn cbc_encrypt_with<C>(
key: &[u8],
iv: &[u8],
data: &[u8],
no_padding: bool,
) -> Result<Vec<u8>, String>
where
cbc::Encryptor<C>: KeyIvInit + BlockModeEncrypt,
C: aes::cipher::BlockCipherEncrypt,
@ -143,7 +148,12 @@ where
Ok(result.to_vec())
}
fn cbc_decrypt_with<C>(key: &[u8], iv: &[u8], data: &[u8], no_padding: bool) -> Result<Vec<u8>, String>
fn cbc_decrypt_with<C>(
key: &[u8],
iv: &[u8],
data: &[u8],
no_padding: bool,
) -> Result<Vec<u8>, String>
where
cbc::Decryptor<C>: KeyIvInit + BlockModeDecrypt,
C: aes::cipher::BlockCipherDecrypt,
@ -271,7 +281,12 @@ pub fn sign(algorithm: &str, private_key_der: &[u8], data: &[u8]) -> Result<Vec<
Err(format!("unsupported signature algorithm: {algorithm}"))
}
pub fn verify(algorithm: &str, public_key_der: &[u8], signature: &[u8], data: &[u8]) -> Result<bool, String> {
pub fn verify(
algorithm: &str,
public_key_der: &[u8],
signature: &[u8],
data: &[u8],
) -> Result<bool, String> {
if let Some(hash) = algorithm.strip_prefix("RSASSA-PKCS1-V1_5-") {
return rsa_verify(hash, public_key_der, signature, data);
}
@ -299,7 +314,12 @@ fn rsa_sign(hash: &str, private_key_der: &[u8], data: &[u8]) -> Result<Vec<u8>,
})
}
fn rsa_verify(hash: &str, public_key_der: &[u8], signature: &[u8], data: &[u8]) -> Result<bool, String> {
fn rsa_verify(
hash: &str,
public_key_der: &[u8],
signature: &[u8],
data: &[u8],
) -> Result<bool, String> {
use rsa::sha2::{Sha256, Sha384, Sha512};
let key = RsaPublicKey::from_public_key_der(public_key_der).map_err(|e| e.to_string())?;
@ -323,8 +343,7 @@ fn ecdsa_sign(private_key_der: &[u8], data: &[u8]) -> Result<Vec<u8>, String> {
use p256::ecdsa::{Signature, SigningKey};
use p256::pkcs8::DecodePrivateKey as EcdsaDecodePrivateKey;
let signing_key =
SigningKey::from_pkcs8_der(private_key_der).map_err(|e| e.to_string())?;
let signing_key = SigningKey::from_pkcs8_der(private_key_der).map_err(|e| e.to_string())?;
let signature: Signature = signing_key.sign(data);
Ok(signature.to_bytes().to_vec())
}
@ -346,8 +365,18 @@ mod tests {
#[test]
fn hmac_sha256_output_is_32_bytes() {
let mac = hmac("SHA256", b"key", b"The quick brown fox jumps over the lazy dog").unwrap();
assert_eq!(mac.len(), 32, "HMAC-SHA256 must be 32 bytes, got {}", mac.len());
let mac = hmac(
"SHA256",
b"key",
b"The quick brown fox jumps over the lazy dog",
)
.unwrap();
assert_eq!(
mac.len(),
32,
"HMAC-SHA256 must be 32 bytes, got {}",
mac.len()
);
let hex: String = mac.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(
hex,
@ -392,7 +421,13 @@ mod tests {
let priv_key = hex_decode(RSA_PRIV_DER_HEX);
let pub_key = hex_decode(RSA_PUB_DER_HEX);
let sig = sign("RSASSA-PKCS1-V1_5-SHA256", &priv_key, RSA_SIGNED_MESSAGE).unwrap();
let ok = verify("RSASSA-PKCS1-V1_5-SHA256", &pub_key, &sig, RSA_SIGNED_MESSAGE).unwrap();
let ok = verify(
"RSASSA-PKCS1-V1_5-SHA256",
&pub_key,
&sig,
RSA_SIGNED_MESSAGE,
)
.unwrap();
assert!(ok);
}
@ -401,7 +436,10 @@ mod tests {
let key = hex_decode(EC_PUB_DER_HEX);
let sig = hex_decode(EC_SIG_HEX);
let ok = verify("ECDSA-SHA256", &key, &sig, EC_SIGNED_MESSAGE).unwrap();
assert!(ok, "should verify a real python `cryptography`-produced P-256 signature");
assert!(
ok,
"should verify a real python `cryptography`-produced P-256 signature"
);
}
#[test]

View file

@ -33,7 +33,9 @@ impl DomBridge {
pub fn load(&self, html: String) -> String {
let mut state = self.state.borrow_mut();
let doc_id = format!("doc_{}", state.next_id());
state.documents.insert(doc_id.clone(), Html::parse_document(&html));
state
.documents
.insert(doc_id.clone(), Html::parse_document(&html));
doc_id
}
@ -43,7 +45,10 @@ impl DomBridge {
return "[]".into();
}
let ids: Vec<NodeId> = match state.documents.get(&doc_id) {
Some(doc) => run_selector(doc, &selector).iter().map(|el| el.id()).collect(),
Some(doc) => run_selector(doc, &selector)
.iter()
.map(|el| el.id())
.collect(),
None => return "[]".into(),
};
cache_and_encode(&mut state, &doc_id, ids)
@ -79,15 +84,23 @@ impl DomBridge {
pub fn html(&self, doc_id: String, element_id: String) -> String {
let state = self.state.borrow();
if element_id.is_empty() {
state.documents.get(&doc_id).map(|d| d.html()).unwrap_or_default()
state
.documents
.get(&doc_id)
.map(|d| d.html())
.unwrap_or_default()
} else {
resolve(&state, &element_id).map(|el| el.inner_html()).unwrap_or_default()
resolve(&state, &element_id)
.map(|el| el.inner_html())
.unwrap_or_default()
}
}
pub fn inner_html(&self, element_id: String) -> String {
let state = self.state.borrow();
resolve(&state, &element_id).map(|el| el.inner_html()).unwrap_or_default()
resolve(&state, &element_id)
.map(|el| el.inner_html())
.unwrap_or_default()
}
pub fn attr(&self, element_id: String, attr_name: String) -> String {
@ -121,7 +134,9 @@ impl DomBridge {
match found {
Some(node_id) => {
let element_id = format!("{doc_id}:{}", state.next_id());
state.elements.insert(element_id.clone(), (doc_id.to_string(), node_id));
state
.elements
.insert(element_id.clone(), (doc_id.to_string(), node_id));
element_id
}
None => "__NONE__".to_string(),
@ -161,12 +176,18 @@ fn split_contains(selector: &str) -> (String, Option<String>) {
let Some(end) = after.find(')') else {
return (selector.to_string(), None);
};
let needle = after[..end].trim_matches(|c| c == '\'' || c == '"').to_string();
let needle = after[..end]
.trim_matches(|c| c == '\'' || c == '"')
.to_string();
let mut base = String::new();
base.push_str(&selector[..start]);
base.push_str(&after[end + 1..]);
let base = base.trim();
let base = if base.is_empty() { "*".to_string() } else { base.to_string() };
let base = if base.is_empty() {
"*".to_string()
} else {
base.to_string()
};
(base, Some(needle))
}
@ -202,7 +223,9 @@ fn cache_and_encode(state: &mut DomState, doc_id: &str, ids: Vec<NodeId>) -> Str
.into_iter()
.map(|node_id| {
let element_id = format!("{doc_id}:{}", state.next_id());
state.elements.insert(element_id.clone(), (doc_id.to_string(), node_id));
state
.elements
.insert(element_id.clone(), (doc_id.to_string(), node_id));
element_id
})
.collect();

View file

@ -74,7 +74,10 @@ pub fn execute_scraper(
}
pub fn get_settings_layout(code: String, scraper_id: String) -> String {
let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return "[]".to_string(),
};
@ -239,7 +242,9 @@ async fn run(
.set_interrupt_handler(Some(Box::new(move || std::time::Instant::now() > deadline)))
.await;
tokio::task::spawn_local(qjs_rt.drive());
let ctx = AsyncContext::full(&qjs_rt).await.map_err(|e| e.to_string())?;
let ctx = AsyncContext::full(&qjs_rt)
.await
.map_err(|e| e.to_string())?;
let captured: Arc<Mutex<Option<String>>> = Default::default();
let captured_clone = captured.clone();
@ -359,8 +364,7 @@ fn native_fetch(
body: Option<String>,
follow_redirects: bool,
) -> String {
let headers: HashMap<String, String> =
serde_json::from_str(&headers_json).unwrap_or_default();
let headers: HashMap<String, String> = serde_json::from_str(&headers_json).unwrap_or_default();
let response = client.fetch(PluginHttpRequest {
method,
url,
@ -423,7 +427,11 @@ fn register_host_functions(
"__native_fetch",
Function::new(
ctx.clone(),
move |url: String, method: String, headers_json: String, body: Option<String>, follow_redirects: bool| {
move |url: String,
method: String,
headers_json: String,
body: Option<String>,
follow_redirects: bool| {
native_fetch(&client, url, method, headers_json, body, follow_redirects)
},
)?,
@ -448,7 +456,9 @@ fn register_host_functions(
"__cheerio_find",
Function::new(
ctx.clone(),
move |doc_id: String, element_id: String, selector: String| d.find(doc_id, element_id, selector),
move |doc_id: String, element_id: String, selector: String| {
d.find(doc_id, element_id, selector)
},
)?,
)?;
@ -479,9 +489,12 @@ fn register_host_functions(
let d = dom.clone();
ctx.globals().set(
"__cheerio_attr",
Function::new(ctx.clone(), move |_doc_id: String, element_id: String, attr_name: String| {
d.attr(element_id, attr_name)
})?,
Function::new(
ctx.clone(),
move |_doc_id: String, element_id: String, attr_name: String| {
d.attr(element_id, attr_name)
},
)?,
)?;
let d = dom.clone();
@ -502,7 +515,9 @@ fn register_host_functions(
ctx.globals().set(
"__crypto_get_random_values_hex",
Function::new(ctx.clone(), |len: usize| to_hex(&crypto_bridge::random_bytes(len)))?,
Function::new(ctx.clone(), |len: usize| {
to_hex(&crypto_bridge::random_bytes(len))
})?,
)?;
ctx.globals().set(
@ -516,18 +531,25 @@ fn register_host_functions(
ctx.globals().set(
"__crypto_hmac_hex_raw",
Function::new(ctx.clone(), |algorithm: String, key_hex: String, data_hex: String| {
crypto_bridge::hmac(&algorithm, &from_hex(&key_hex), &from_hex(&data_hex))
.map(|bytes| to_hex(&bytes))
.unwrap_or_default()
})?,
Function::new(
ctx.clone(),
|algorithm: String, key_hex: String, data_hex: String| {
crypto_bridge::hmac(&algorithm, &from_hex(&key_hex), &from_hex(&data_hex))
.map(|bytes| to_hex(&bytes))
.unwrap_or_default()
},
)?,
)?;
ctx.globals().set(
"__crypto_pbkdf2_hex",
Function::new(
ctx.clone(),
|password_hex: String, salt_hex: String, iterations: u32, key_size_bits: u32, algorithm: String| {
|password_hex: String,
salt_hex: String,
iterations: u32,
key_size_bits: u32,
algorithm: String| {
crypto_bridge::pbkdf2(
&from_hex(&password_hex),
&from_hex(&salt_hex),
@ -546,9 +568,14 @@ fn register_host_functions(
Function::new(
ctx.clone(),
|mode: String, key_hex: String, iv_hex: String, data_hex: String| {
crypto_bridge::aes_encrypt(&mode, &from_hex(&key_hex), &from_hex(&iv_hex), &from_hex(&data_hex))
.map(|bytes| to_hex(&bytes))
.unwrap_or_default()
crypto_bridge::aes_encrypt(
&mode,
&from_hex(&key_hex),
&from_hex(&iv_hex),
&from_hex(&data_hex),
)
.map(|bytes| to_hex(&bytes))
.unwrap_or_default()
},
)?,
)?;
@ -558,9 +585,14 @@ fn register_host_functions(
Function::new(
ctx.clone(),
|mode: String, key_hex: String, iv_hex: String, data_hex: String| {
crypto_bridge::aes_decrypt(&mode, &from_hex(&key_hex), &from_hex(&iv_hex), &from_hex(&data_hex))
.map(|bytes| to_hex(&bytes))
.unwrap_or_default()
crypto_bridge::aes_decrypt(
&mode,
&from_hex(&key_hex),
&from_hex(&iv_hex),
&from_hex(&data_hex),
)
.map(|bytes| to_hex(&bytes))
.unwrap_or_default()
},
)?,
)?;
@ -579,11 +611,14 @@ fn register_host_functions(
ctx.globals().set(
"__crypto_sign_hex",
Function::new(ctx.clone(), |algorithm: String, key_hex: String, data_hex: String| {
crypto_bridge::sign(&algorithm, &from_hex(&key_hex), &from_hex(&data_hex))
.map(|bytes| to_hex(&bytes))
.unwrap_or_default()
})?,
Function::new(
ctx.clone(),
|algorithm: String, key_hex: String, data_hex: String| {
crypto_bridge::sign(&algorithm, &from_hex(&key_hex), &from_hex(&data_hex))
.map(|bytes| to_hex(&bytes))
.unwrap_or_default()
},
)?,
)?;
ctx.globals().set(

View file

@ -54,8 +54,14 @@ pub(crate) fn plugin_execution_plan_json(payload: &str) -> Option<String> {
.scrapers
.into_iter()
.filter(|scraper| {
scraper.get("enabled").and_then(Value::as_bool).unwrap_or(true)
&& scraper.get("id").and_then(Value::as_str).is_some_and(|id| !id.trim().is_empty())
scraper
.get("enabled")
.and_then(Value::as_bool)
.unwrap_or(true)
&& scraper
.get("id")
.and_then(Value::as_str)
.is_some_and(|id| !id.trim().is_empty())
&& scraper
.get("filename")
.and_then(Value::as_str)

View file

@ -165,8 +165,11 @@ fn profile_safe_prefs(profile: &Value) -> ProfileSafePrefs {
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(),
continue_watching_source: safe_continue_watching_source(text(profile, "continueWatchingSource"))
.to_string(),
continue_watching_source: safe_continue_watching_source(text(
profile,
"continueWatchingSource",
))
.to_string(),
card_layout: card_layout.clone(),
continue_watching_layout: continue_watching_layout.clone(),
continue_watching_artwork: text(profile, "continueWatchingArtwork")

View file

@ -634,8 +634,12 @@ fn resolve_profile_audio_language(
return Some("ja".to_string());
}
match preference {
Some("original") => original_language.filter(|value| !value.is_empty()).map(str::to_string),
Some("device_language") => device_language.filter(|value| !value.is_empty()).map(str::to_string),
Some("original") => original_language
.filter(|value| !value.is_empty())
.map(str::to_string),
Some("device_language") => device_language
.filter(|value| !value.is_empty())
.map(str::to_string),
Some(value) => Some(value.to_string()),
None => None,
}
@ -795,7 +799,10 @@ pub(crate) fn player_track_state_json(request_json: &str) -> Option<String> {
let profile_audio_language = resolve_profile_audio_language(
&request.content_genres,
request.anime_prefer_japanese_audio,
request.profile_audio_language.as_deref().filter(|value| !value.is_empty()),
request
.profile_audio_language
.as_deref()
.filter(|value| !value.is_empty()),
request.original_language.as_deref(),
request.device_language.as_deref(),
);
@ -804,7 +811,11 @@ pub(crate) fn player_track_state_json(request_json: &str) -> Option<String> {
.last_audio_language
.as_deref()
.filter(|value| !value.is_empty()),
request.preferred_audio_language.as_deref().filter(|value| !value.is_empty()).or(profile_audio_language.as_deref()),
request
.preferred_audio_language
.as_deref()
.filter(|value| !value.is_empty())
.or(profile_audio_language.as_deref()),
request
.original_language
.as_deref()

View file

@ -334,10 +334,12 @@ mod tests {
#[test]
fn shared_parser_handles_short_vtt_and_timed_text() {
let vtt = parse_subtitle_cues_with_text("WEBVTT\n\n01.000 --> 02.500\n<b>Hello</b> &amp; world");
let vtt =
parse_subtitle_cues_with_text("WEBVTT\n\n01.000 --> 02.500\n<b>Hello</b> &amp; world");
assert_eq!(vtt[0].start, 1.0);
assert_eq!(vtt[0].text, "Hello & world");
let timed = parse_subtitle_cues_with_text("<timedtext><p d='500' t='1000'>Hi</p></timedtext>");
let timed =
parse_subtitle_cues_with_text("<timedtext><p d='500' t='1000'>Hi</p></timedtext>");
assert_eq!(timed[0].end, 1.5);
}
}

View file

@ -244,13 +244,10 @@ pub(crate) fn air_date_refresh_candidates_json(args_json: &str) -> Option<String
Some(ms) => ms <= now_ms,
None => true,
};
let missing_episode_details = [
"nextEpisodeSeason",
"nextEpisodeNumber",
"nextEpisodeTitle",
]
.iter()
.any(|key| item.get(*key).is_none() || item.get(*key) == Some(&Value::Null));
let missing_episode_details =
["nextEpisodeSeason", "nextEpisodeNumber", "nextEpisodeTitle"]
.iter()
.any(|key| item.get(*key).is_none() || item.get(*key) == Some(&Value::Null));
if !missing_or_past && !missing_episode_details {
continue;
}
@ -635,4 +632,3 @@ pub(crate) fn export_collections_json(collections_json: &str) -> Option<String>
.collect();
serde_json::to_string(&data).ok()
}