mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-07-27 04:12:10 +00:00
chore: format core sources
This commit is contained in:
parent
4e787268b8
commit
1fab5d27c2
28 changed files with 714 additions and 253 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}")),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -207,4 +207,3 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_drain
|
|||
}))
|
||||
.unwrap_or(ptr::null_mut())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
50
src/ffi.rs
50
src/ffi.rs
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
10
src/lib.rs
10
src/lib.rs
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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> & world");
|
||||
let vtt =
|
||||
parse_subtitle_cues_with_text("WEBVTT\n\n01.000 --> 02.500\n<b>Hello</b> & 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue