diff --git a/fluxa-streaming-engine/src/chapters.rs b/fluxa-streaming-engine/src/chapters.rs index b6c46ae..a30b769 100644 --- a/fluxa-streaming-engine/src/chapters.rs +++ b/fluxa-streaming-engine/src/chapters.rs @@ -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 = chapters .iter() diff --git a/fluxa-streaming-engine/src/dv_rewrite.rs b/fluxa-streaming-engine/src/dv_rewrite.rs index 8f0f8ae..219f5c8 100644 --- a/fluxa-streaming-engine/src/dv_rewrite.rs +++ b/fluxa-streaming-engine/src/dv_rewrite.rs @@ -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] diff --git a/fluxa-streaming-engine/src/dv_rewrite/dvcc.rs b/fluxa-streaming-engine/src/dv_rewrite/dvcc.rs index 470c2e5..8086ac1 100644 --- a/fluxa-streaming-engine/src/dv_rewrite/dvcc.rs +++ b/fluxa-streaming-engine/src/dv_rewrite/dvcc.rs @@ -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 { 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, + }); } } } diff --git a/fluxa-streaming-engine/src/dv_rewrite/hls_urls.rs b/fluxa-streaming-engine/src/dv_rewrite/hls_urls.rs index 14da909..68520de 100644 --- a/fluxa-streaming-engine/src/dv_rewrite/hls_urls.rs +++ b/fluxa-streaming-engine/src/dv_rewrite/hls_urls.rs @@ -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::>().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::>() + .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}")), } } diff --git a/fluxa-streaming-engine/src/torrent_engine/http.rs b/fluxa-streaming-engine/src/torrent_engine/http.rs index c23e74d..5c2aabf 100644 --- a/fluxa-streaming-engine/src/torrent_engine/http.rs +++ b/fluxa-streaming-engine/src/torrent_engine/http.rs @@ -1,6 +1,9 @@ use super::*; -pub(super) fn parse_range(value: Option<&HeaderValue>, length: u64) -> Result, ()> { +pub(super) fn parse_range( + value: Option<&HeaderValue>, + length: u64, +) -> Result, ()> { let Some(value) = value else { return Ok(None); }; diff --git a/src/addon_protocol.rs b/src/addon_protocol.rs index 5e9d011..b1b28ca 100644 --- a/src/addon_protocol.rs +++ b/src/addon_protocol.rs @@ -472,7 +472,8 @@ pub(crate) fn parse_catalogs(json: &Value) -> Vec { .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) diff --git a/src/anime_detection.rs b/src/anime_detection.rs index a5a60d4..df8473c 100644 --- a/src/anime_detection.rs +++ b/src/anime_detection.rs @@ -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)); } diff --git a/src/bindings/jni.rs b/src/bindings/jni.rs index 0a2afc4..e2045a5 100644 --- a/src/bindings/jni.rs +++ b/src/bindings/jni.rs @@ -207,4 +207,3 @@ pub unsafe extern "system" fn Java_com_fluxa_app_core_rust_FluxaCoreNative_drain })) .unwrap_or(ptr::null_mut()) } - diff --git a/src/desktop_playback.rs b/src/desktop_playback.rs index b8e2676..41d31ce 100644 --- a/src/desktop_playback.rs +++ b/src/desktop_playback.rs @@ -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"), _ => {} } diff --git a/src/external_sync.rs b/src/external_sync.rs index ca460e4..d1d5c29 100644 --- a/src/external_sync.rs +++ b/src/external_sync.rs @@ -40,7 +40,12 @@ pub(crate) fn simkl_history_request_json(args_json: &str) -> Option { .iter() .filter_map(|(season, episodes)| { let season = season.parse::().ok()?; - let episodes = episodes.as_array()?.iter().filter_map(Value::as_i64).map(|number| json!({ "number": number })).collect::>(); + let episodes = episodes + .as_array()? + .iter() + .filter_map(Value::as_i64) + .map(|number| json!({ "number": number })) + .collect::>(); Some(json!({ "number": season, "episodes": episodes })) }) .collect::>() @@ -553,7 +558,10 @@ pub(crate) fn trakt_scrobble_url(action: &str) -> Option { } pub(crate) fn trakt_playback_url(content_type: Option<&str>) -> Option { - 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 { pub(crate) fn trakt_playback_delete_ids_json(args_json: &str) -> Option { 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::>(); + 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::>(); 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 = 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).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(), diff --git a/src/external_sync/anilist.rs b/src/external_sync/anilist.rs index 093b8b2..c7dda3a 100644 --- a/src/external_sync/anilist.rs +++ b/src/external_sync/anilist.rs @@ -193,19 +193,21 @@ pub(crate) fn anilist_search_best_match_json(args_json: &str) -> Option 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 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 = order.iter().filter_map(|id| by_id.remove(id)).collect(); Value::Array(merged) } - diff --git a/src/ffi.rs b/src/ffi.rs index 11b32a4..71c4db1 100644 --- a/src/ffi.rs +++ b/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)) } diff --git a/src/ffi/addon_resource_routes.rs b/src/ffi/addon_resource_routes.rs index bdb4744..49ac2a3 100644 --- a/src/ffi/addon_resource_routes.rs +++ b/src/ffi/addon_resource_routes.rs @@ -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}`"), + )), } } diff --git a/src/ffi/addon_support_routes.rs b/src/ffi/addon_support_routes.rs index ff3df84..2505ba2 100644 --- a/src/ffi/addon_support_routes.rs +++ b/src/ffi/addon_support_routes.rs @@ -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", + )), } } diff --git a/src/ffi/engine_routes.rs b/src/ffi/engine_routes.rs index 473301a..fcb6f21 100644 --- a/src/ffi/engine_routes.rs +++ b/src/ffi/engine_routes.rs @@ -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}`"), + )), } } diff --git a/src/ffi/external_sync_routes.rs b/src/ffi/external_sync_routes.rs index b974ef6..eb801f4 100644 --- a/src/ffi/external_sync_routes.rs +++ b/src/ffi/external_sync_routes.rs @@ -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" => { diff --git a/src/ffi/library_routes.rs b/src/ffi/library_routes.rs index 1a2417a..ee922a9 100644 --- a/src/ffi/library_routes.rs +++ b/src/ffi/library_routes.rs @@ -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)?; diff --git a/src/home_ranking.rs b/src/home_ranking.rs index a1c11e5..ac23997 100644 --- a/src/home_ranking.rs +++ b/src/home_ranking.rs @@ -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::>(); - 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::>(); - 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::>(); + 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::>(); + 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 = 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::>(); + let available = feeds + .iter() + .filter_map(|feed| feed.get("key").and_then(Value::as_str)) + .map(str::to_string) + .collect::>(); 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::>(&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::>(); + 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::>(&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::>(); 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::>(); - 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"); } diff --git a/src/lib.rs b/src/lib.rs index 1333cda..c19c18f 100644 --- a/src/lib.rs +++ b/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; diff --git a/src/nuvio_sync.rs b/src/nuvio_sync.rs index cbbbd19..1d0cdd5 100644 --- a/src/nuvio_sync.rs +++ b/src/nuvio_sync.rs @@ -40,7 +40,11 @@ pub(crate) fn addon_state_json(args_json: &str) -> Option { 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 { 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::>(); serde_json::to_string(&items).ok() } pub(crate) fn playback_progress_request_json(args_json: &str) -> Option { - 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::().ok()).flatten(); let episode = (parts.len() == 3).then(|| parts[2].parse::().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::().ok()) + .flatten(); + let episode = (parts.len() == 3) + .then(|| parts[2].parse::().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 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 = 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::>(); - 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::>(); - 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::>(); + 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::>(); + 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::>(); + 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::>(); serde_json::to_string(&json!({"deleteIds":delete_ids,"updates":updates,"creates":creates})).ok() } diff --git a/src/plugin_runtime/crypto_bridge.rs b/src/plugin_runtime/crypto_bridge.rs index 5d1df9d..84582e7 100644 --- a/src/plugin_runtime/crypto_bridge.rs +++ b/src/plugin_runtime/crypto_bridge.rs @@ -123,7 +123,12 @@ fn cbc_decrypt(key: &[u8], iv: &[u8], data: &[u8], no_padding: bool) -> Result(key: &[u8], iv: &[u8], data: &[u8], no_padding: bool) -> Result, String> +fn cbc_encrypt_with( + key: &[u8], + iv: &[u8], + data: &[u8], + no_padding: bool, +) -> Result, String> where cbc::Encryptor: KeyIvInit + BlockModeEncrypt, C: aes::cipher::BlockCipherEncrypt, @@ -143,7 +148,12 @@ where Ok(result.to_vec()) } -fn cbc_decrypt_with(key: &[u8], iv: &[u8], data: &[u8], no_padding: bool) -> Result, String> +fn cbc_decrypt_with( + key: &[u8], + iv: &[u8], + data: &[u8], + no_padding: bool, +) -> Result, String> where cbc::Decryptor: KeyIvInit + BlockModeDecrypt, C: aes::cipher::BlockCipherDecrypt, @@ -271,7 +281,12 @@ pub fn sign(algorithm: &str, private_key_der: &[u8], data: &[u8]) -> Result Result { +pub fn verify( + algorithm: &str, + public_key_der: &[u8], + signature: &[u8], + data: &[u8], +) -> Result { 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, }) } -fn rsa_verify(hash: &str, public_key_der: &[u8], signature: &[u8], data: &[u8]) -> Result { +fn rsa_verify( + hash: &str, + public_key_der: &[u8], + signature: &[u8], + data: &[u8], +) -> Result { 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, 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] diff --git a/src/plugin_runtime/dom_bridge.rs b/src/plugin_runtime/dom_bridge.rs index 2333d25..df5b0e9 100644 --- a/src/plugin_runtime/dom_bridge.rs +++ b/src/plugin_runtime/dom_bridge.rs @@ -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 = 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) { 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) -> 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(); diff --git a/src/plugin_runtime/mod.rs b/src/plugin_runtime/mod.rs index 779842e..3a6688e 100644 --- a/src/plugin_runtime/mod.rs +++ b/src/plugin_runtime/mod.rs @@ -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>> = Default::default(); let captured_clone = captured.clone(); @@ -359,8 +364,7 @@ fn native_fetch( body: Option, follow_redirects: bool, ) -> String { - let headers: HashMap = - serde_json::from_str(&headers_json).unwrap_or_default(); + let headers: HashMap = 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, follow_redirects: bool| { + move |url: String, + method: String, + headers_json: String, + body: Option, + 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( diff --git a/src/plugins.rs b/src/plugins.rs index e397c72..e204bf5 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -54,8 +54,14 @@ pub(crate) fn plugin_execution_plan_json(payload: &str) -> Option { .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) diff --git a/src/profile_prefs.rs b/src/profile_prefs.rs index 069b3dd..e676c1c 100644 --- a/src/profile_prefs.rs +++ b/src/profile_prefs.rs @@ -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") diff --git a/src/stream_policy.rs b/src/stream_policy.rs index 56511e8..f368703 100644 --- a/src/stream_policy.rs +++ b/src/stream_policy.rs @@ -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 { 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 { .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() diff --git a/src/subtitle_sync.rs b/src/subtitle_sync.rs index 6d47819..644cf57 100644 --- a/src/subtitle_sync.rs +++ b/src/subtitle_sync.rs @@ -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\nHello & world"); + let vtt = + parse_subtitle_cues_with_text("WEBVTT\n\n01.000 --> 02.500\nHello & world"); assert_eq!(vtt[0].start, 1.0); assert_eq!(vtt[0].text, "Hello & world"); - let timed = parse_subtitle_cues_with_text("

Hi

"); + let timed = + parse_subtitle_cues_with_text("

Hi

"); assert_eq!(timed[0].end, 1.5); } } diff --git a/src/watchlist_plan/collections.rs b/src/watchlist_plan/collections.rs index 90854c9..8d3121f 100644 --- a/src/watchlist_plan/collections.rs +++ b/src/watchlist_plan/collections.rs @@ -244,13 +244,10 @@ pub(crate) fn air_date_refresh_candidates_json(args_json: &str) -> Option 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 .collect(); serde_json::to_string(&data).ok() } -