mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-08-19 13:35:53 +00:00
refactor: split search_plan.rs into per-concern submodules
search_plan.rs was 1262 lines mixing search, discover, and addon-catalog
concerns. Split into search_plan/{search,discover,addon_catalog,
library_sort,detail_nav}.rs:
- search.rs: search suggestions/screen/result-grouping/merge plans
- discover.rs: discover page merging, selection, and sort plans
- addon_catalog.rs: addon manifest/catalog helpers, metadata feed options,
transport URL resolution, feed genre resolution
- library_sort.rs: library sort plan
- detail_nav.rs: detail series lookup id, season load plan
search_plan.rs is now just module wiring plus the existing test suite.
No behavior change — same 393 tests pass.
This commit is contained in:
parent
e59cbcb2a6
commit
41e19ee76f
6 changed files with 1132 additions and 1107 deletions
1125
src/search_plan.rs
1125
src/search_plan.rs
File diff suppressed because it is too large
Load diff
455
src/search_plan/addon_catalog.rs
Normal file
455
src/search_plan/addon_catalog.rs
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
use crate::{addon_protocol, content_identity};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
fn manifest_value(addon: &Value) -> Option<&Value> {
|
||||
addon.get("manifest").or(Some(addon))
|
||||
}
|
||||
|
||||
fn addon_transport_url(addon: &Value) -> &str {
|
||||
addon
|
||||
.get("transportUrl")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
fn addon_manifest_name(addon: &Value) -> String {
|
||||
let manifest = manifest_value(addon).unwrap_or(addon);
|
||||
manifest
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| manifest.get("id").and_then(Value::as_str))
|
||||
.unwrap_or("Metadata")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn title_label(value: &str) -> String {
|
||||
let label = value
|
||||
.split(['_', '-', ' '])
|
||||
.filter(|part| !part.is_empty())
|
||||
.map(|part| {
|
||||
let mut chars = part.chars();
|
||||
match chars.next() {
|
||||
Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if label.is_empty() {
|
||||
value.to_string()
|
||||
} else {
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_feed_home_title(label: &str) -> String {
|
||||
let parts = label
|
||||
.split(" - ")
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
match parts.len() {
|
||||
0 => label.to_string(),
|
||||
1 => parts[0].to_string(),
|
||||
2 => parts[1].to_string(),
|
||||
_ => parts[1..].join(" "),
|
||||
}
|
||||
}
|
||||
|
||||
fn discover_catalog_label(raw_name: Option<&str>, id: &str) -> String {
|
||||
let fallback = title_label(id);
|
||||
let base = raw_name
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(&fallback);
|
||||
let mut label = base
|
||||
.split(['-', ':', '|', '/'])
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
for word in [
|
||||
"cinemeta", "movie", "movies", "film", "films", "series", "shows", "tv",
|
||||
] {
|
||||
label = label
|
||||
.split_whitespace()
|
||||
.filter(|part| !part.eq_ignore_ascii_case(word))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
}
|
||||
if label.trim().is_empty() {
|
||||
fallback
|
||||
} else {
|
||||
label
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_extras(catalog: &Value) -> Vec<Value> {
|
||||
catalog
|
||||
.get("extra")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|extra| {
|
||||
let name = extra.get("name").and_then(Value::as_str)?.trim();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let options = extra
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|option| option.as_str().map(str::trim).map(str::to_string))
|
||||
.filter(|option| !option.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let default_value = extra.get("default").and_then(Value::as_str);
|
||||
(!options.is_empty()).then(|| {
|
||||
json!({
|
||||
"name": name,
|
||||
"options": options,
|
||||
"default": default_value,
|
||||
"isRequired": catalog_requires_extra(catalog, name)
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn manifest_supports_catalog(manifest: &Value) -> bool {
|
||||
serde_json::to_string(manifest)
|
||||
.ok()
|
||||
.is_some_and(|json| addon_protocol::supports_resource(&json, "catalog", None, None))
|
||||
}
|
||||
|
||||
fn catalog_has_required_extra_except(catalog: &Value, allowed: &[&str]) -> bool {
|
||||
let allowed_json =
|
||||
serde_json::to_string(&allowed.iter().map(|s| s.to_string()).collect::<Vec<_>>())
|
||||
.unwrap_or_else(|_| "[]".to_string());
|
||||
serde_json::to_string(catalog)
|
||||
.ok()
|
||||
.is_some_and(|json| addon_protocol::catalog_has_required_extra_except(&json, &allowed_json))
|
||||
}
|
||||
|
||||
fn catalog_requires_extra(catalog: &Value, extra_name: &str) -> bool {
|
||||
serde_json::to_string(catalog)
|
||||
.ok()
|
||||
.is_some_and(|json| addon_protocol::catalog_requires_extra(&json, extra_name))
|
||||
}
|
||||
|
||||
pub(crate) fn build_metadata_feed_options_json(addons_json: &str) -> Option<String> {
|
||||
let addons = serde_json::from_str::<Vec<Value>>(addons_json).ok()?;
|
||||
let mut feeds = Vec::new();
|
||||
for addon in addons {
|
||||
let Some(manifest) = manifest_value(&addon) else {
|
||||
continue;
|
||||
};
|
||||
if !manifest_supports_catalog(manifest) {
|
||||
continue;
|
||||
}
|
||||
let addon_name = addon_manifest_name(&addon);
|
||||
let transport_url = addon_transport_url(&addon);
|
||||
let source_key = manifest
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(transport_url);
|
||||
for catalog in manifest
|
||||
.get("catalogs")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let Some(type_value) = catalog
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(id) = catalog
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if catalog_has_required_extra_except(catalog, &[]) {
|
||||
continue;
|
||||
}
|
||||
let name = catalog
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| title_label(id));
|
||||
let key = format!(
|
||||
"addon:{}:{}:{}",
|
||||
content_identity::stable_feed_part(source_key),
|
||||
content_identity::stable_feed_part(type_value),
|
||||
content_identity::stable_feed_part(id)
|
||||
);
|
||||
let label = format!("{addon_name} - {name}");
|
||||
feeds.push(json!({
|
||||
"key": key,
|
||||
"label": label,
|
||||
"homeTitle": metadata_feed_home_title(&label),
|
||||
"transportUrl": transport_url,
|
||||
"type": type_value,
|
||||
"id": id,
|
||||
"genre": Value::Null
|
||||
}));
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&feeds).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn discover_catalog_options_json(
|
||||
addons_json: &str,
|
||||
selected_type: &str,
|
||||
) -> Option<String> {
|
||||
let addons = serde_json::from_str::<Vec<Value>>(addons_json).ok()?;
|
||||
let lower_selected_type = selected_type.to_lowercase();
|
||||
let normalized_type = content_identity::normalize_content_type(&lower_selected_type)
|
||||
.map(str::to_string)
|
||||
.unwrap_or(lower_selected_type);
|
||||
let mut options = Vec::new();
|
||||
for addon in addons {
|
||||
let Some(manifest) = manifest_value(&addon) else {
|
||||
continue;
|
||||
};
|
||||
if !manifest_supports_catalog(manifest) {
|
||||
continue;
|
||||
}
|
||||
let transport_url = addon_transport_url(&addon);
|
||||
for catalog in manifest
|
||||
.get("catalogs")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let Some(type_value) = catalog
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(content_identity::normalize_content_type)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if normalized_type != "all" && normalized_type != type_value {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = catalog
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let extras = catalog_extras(catalog);
|
||||
let supported_extra_names = extras
|
||||
.iter()
|
||||
.filter_map(|extra| extra.get("name").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>();
|
||||
if catalog_has_required_extra_except(catalog, &supported_extra_names) {
|
||||
continue;
|
||||
}
|
||||
let catalog_label =
|
||||
discover_catalog_label(catalog.get("name").and_then(Value::as_str), id);
|
||||
let genre_extra = extras
|
||||
.iter()
|
||||
.find(|extra| extra.get("name").and_then(Value::as_str) == Some("genre"));
|
||||
let genres: Vec<&str> = genre_extra
|
||||
.and_then(|extra| extra.get("options"))
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
let requires_genre = genre_extra
|
||||
.and_then(|extra| extra.get("isRequired"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let default_genre = catalog
|
||||
.get("extra")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|extra| extra.get("name").and_then(Value::as_str) == Some("genre"))
|
||||
.and_then(|extra| extra.get("default"))
|
||||
.and_then(Value::as_str);
|
||||
options.push(json!({
|
||||
"key": format!(
|
||||
"discover:{}:{}:{}",
|
||||
content_identity::stable_feed_part(transport_url),
|
||||
content_identity::stable_feed_part(type_value),
|
||||
content_identity::stable_feed_part(id)
|
||||
),
|
||||
"label": catalog_label,
|
||||
"transportUrl": transport_url,
|
||||
"type": type_value,
|
||||
"id": id,
|
||||
"genres": genres,
|
||||
"requiresGenre": requires_genre,
|
||||
"defaultGenre": default_genre,
|
||||
"extras": extras
|
||||
}));
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&options).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn discover_content_types_json(addons_json: &str) -> Option<String> {
|
||||
let options: Vec<Value> =
|
||||
serde_json::from_str(&discover_catalog_options_json(addons_json, "all")?).ok()?;
|
||||
let mut types = vec!["movie".to_string(), "series".to_string()];
|
||||
for option in &options {
|
||||
let extras = option
|
||||
.get("extras")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let search_required = extras.iter().any(|extra| {
|
||||
extra.get("name").and_then(Value::as_str) == Some("search")
|
||||
&& extra.get("isRequired").and_then(Value::as_bool) == Some(true)
|
||||
});
|
||||
let has_browsable_extra = extras.iter().any(|extra| {
|
||||
!matches!(
|
||||
extra.get("name").and_then(Value::as_str),
|
||||
Some("search" | "skip")
|
||||
) && extra
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|options| !options.is_empty())
|
||||
});
|
||||
if search_required && !has_browsable_extra {
|
||||
continue;
|
||||
}
|
||||
let Some(content_type) = option.get("type").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if !types.iter().any(|value| value == content_type) {
|
||||
types.push(content_type.to_string());
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&types).ok()
|
||||
}
|
||||
|
||||
/// Given a catalog source `{catalogId, type, addonId?}` and an array of addon
|
||||
/// descriptors, returns the first matching `transportUrl`, or `null`.
|
||||
pub(crate) fn resolve_transport_url_json(source_json: &str, addons_json: &str) -> Option<String> {
|
||||
let source: Value = serde_json::from_str(source_json).ok()?;
|
||||
let addons: Vec<Value> = serde_json::from_str(addons_json).ok()?;
|
||||
|
||||
let src_addon_id = source
|
||||
.get("addonId")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_lowercase);
|
||||
let src_catalog_id = source.get("catalogId").and_then(Value::as_str)?;
|
||||
let normalize_type = |v: &str| -> String {
|
||||
match v.trim().to_lowercase().as_str() {
|
||||
"movies" => "movie".to_string(),
|
||||
"series" | "tv" | "show" | "shows" => "series".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
};
|
||||
let src_type = source
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.map(normalize_type);
|
||||
|
||||
for addon in &addons {
|
||||
let manifest = addon.get("manifest")?;
|
||||
let addon_id = manifest
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let t_url = addon
|
||||
.get("transportUrl")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
if let Some(ref wanted_addon_id) = src_addon_id {
|
||||
if !(addon_id == *wanted_addon_id
|
||||
|| t_url.to_lowercase().contains(wanted_addon_id.as_str()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let catalogs = manifest
|
||||
.get("catalogs")
|
||||
.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[]);
|
||||
let matches = catalogs.iter().any(|cat| {
|
||||
cat.get("id").and_then(Value::as_str) == Some(src_catalog_id)
|
||||
&& src_type.as_deref().is_none_or(|st| {
|
||||
cat.get("type").and_then(Value::as_str).map(&normalize_type)
|
||||
== Some(st.to_string())
|
||||
})
|
||||
});
|
||||
if matches {
|
||||
return serde_json::to_string(t_url).ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolves the effective genre for a metadata feed option by inspecting the
|
||||
/// corresponding catalog's `extra` array for a `genre` field with a default or
|
||||
/// first required value.
|
||||
pub(crate) fn resolve_feed_option_genre_json(
|
||||
feed_option_json: &str,
|
||||
addons_json: &str,
|
||||
) -> Option<String> {
|
||||
let option: Value = serde_json::from_str(feed_option_json).ok()?;
|
||||
let addons: Vec<Value> = serde_json::from_str(addons_json).ok()?;
|
||||
|
||||
// If genre is already set on the option, return it.
|
||||
if let Some(genre) = option
|
||||
.get("genre")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
{
|
||||
return serde_json::to_string(genre).ok();
|
||||
}
|
||||
|
||||
let transport_url = option.get("transportUrl").and_then(Value::as_str)?;
|
||||
let opt_type = option.get("type").and_then(Value::as_str)?;
|
||||
let opt_id = option.get("id").and_then(Value::as_str)?;
|
||||
|
||||
let addon = addons
|
||||
.iter()
|
||||
.find(|a| a.get("transportUrl").and_then(Value::as_str) == Some(transport_url))?;
|
||||
let catalogs = addon
|
||||
.get("manifest")
|
||||
.and_then(|m| m.get("catalogs"))
|
||||
.and_then(Value::as_array)?;
|
||||
let catalog = catalogs.iter().find(|cat| {
|
||||
cat.get("type").and_then(Value::as_str) == Some(opt_type)
|
||||
&& cat.get("id").and_then(Value::as_str) == Some(opt_id)
|
||||
})?;
|
||||
|
||||
let extras = catalog.get("extra").and_then(Value::as_array)?;
|
||||
let genre_extra = extras
|
||||
.iter()
|
||||
.find(|e| e.get("name").and_then(Value::as_str) == Some("genre"))?;
|
||||
|
||||
let default_genre = genre_extra
|
||||
.get("default")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.trim().is_empty());
|
||||
let is_required = genre_extra
|
||||
.get("isRequired")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let first_option = genre_extra
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|opts| opts.first())
|
||||
.and_then(Value::as_str);
|
||||
|
||||
let resolved = default_genre.or(if is_required { first_option } else { None })?;
|
||||
serde_json::to_string(resolved).ok()
|
||||
}
|
||||
74
src/search_plan/detail_nav.rs
Normal file
74
src/search_plan/detail_nav.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
use serde_json::{json, Value};
|
||||
|
||||
pub(crate) fn detail_series_lookup_id(raw_id: &str) -> String {
|
||||
let trimmed = raw_id.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if let Some(imdb) = extract_imdb_id(trimmed) {
|
||||
return imdb;
|
||||
}
|
||||
// Strip trailing season:episode parts (e.g. "kitsu:777:1:2" -> "kitsu:777", "base:1:2" -> "base")
|
||||
let parts: Vec<&str> = trimmed.split(':').collect();
|
||||
if parts.len() >= 3 {
|
||||
let last = parts[parts.len() - 1];
|
||||
let second_last = parts[parts.len() - 2];
|
||||
if last.parse::<i32>().is_ok() && second_last.parse::<i32>().is_ok() {
|
||||
return parts[..parts.len() - 2].join(":");
|
||||
}
|
||||
}
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
fn extract_imdb_id(raw: &str) -> Option<String> {
|
||||
let mut start = 0;
|
||||
let bytes = raw.as_bytes();
|
||||
while start < bytes.len() {
|
||||
if bytes[start] == b't' && start + 2 < bytes.len() && bytes[start + 1] == b't' {
|
||||
let end = bytes[start..]
|
||||
.iter()
|
||||
.take_while(|&&b| b.is_ascii_digit() || (b == b't' && start == 0))
|
||||
.count();
|
||||
let candidate = &raw[start..start + end];
|
||||
if candidate.starts_with("tt")
|
||||
&& candidate[2..].chars().all(|c| c.is_ascii_digit())
|
||||
&& candidate.len() > 3
|
||||
{
|
||||
return Some(candidate.to_string());
|
||||
}
|
||||
}
|
||||
start += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn detail_season_load_plan_json(request_json: &str) -> Option<String> {
|
||||
let value: Value = serde_json::from_str(request_json).ok()?;
|
||||
let saved_video_id = value
|
||||
.get("savedVideoId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let seasons_count = value
|
||||
.get("seasonsCount")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(1)
|
||||
.max(1) as i32;
|
||||
|
||||
let saved_season = saved_video_id
|
||||
.split(':')
|
||||
.nth(1)
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let first_season = if saved_season > 0 && saved_season <= seasons_count {
|
||||
saved_season
|
||||
} else {
|
||||
1
|
||||
};
|
||||
|
||||
serde_json::to_string(&json!({
|
||||
"firstSeasonToLoad": first_season,
|
||||
"savedSeason": if saved_season > 0 { json!(saved_season) } else { Value::Null }
|
||||
}))
|
||||
.ok()
|
||||
}
|
||||
220
src/search_plan/discover.rs
Normal file
220
src/search_plan/discover.rs
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Discover aggregates results from every installed addon's catalogs — with enough
|
||||
// addons installed, that's thousands of items in one IPC payload. Cap it after
|
||||
// dedup/sort so a single discover fetch can't balloon into multi-megabyte responses.
|
||||
const DISCOVER_MAX_ITEMS: usize = 400;
|
||||
|
||||
pub(crate) fn merge_discover_pages_json(request_json: &str) -> Option<String> {
|
||||
let request: Value = serde_json::from_str(request_json).ok()?;
|
||||
let base = request.get("baseItems")?.as_array()?;
|
||||
let existing = request.get("existingItems")?.as_array()?;
|
||||
let incoming = request.get("incomingItems")?.as_array()?;
|
||||
let mut seen = HashSet::new();
|
||||
let mut merged = Vec::new();
|
||||
for item in base.iter().chain(existing).chain(incoming) {
|
||||
let id = item.get("id").and_then(Value::as_str).unwrap_or("");
|
||||
if !id.is_empty() && seen.insert(id) {
|
||||
merged.push(item.clone());
|
||||
}
|
||||
}
|
||||
let existing_ids: HashSet<&str> = base
|
||||
.iter()
|
||||
.chain(existing)
|
||||
.filter_map(|item| item.get("id").and_then(Value::as_str))
|
||||
.collect();
|
||||
let mut appended_seen = existing_ids.clone();
|
||||
let appended: Vec<&Value> = incoming
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
item.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|id| appended_seen.insert(id))
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string(&json!({
|
||||
"items": merged,
|
||||
"appendedItems": appended,
|
||||
"exhausted": incoming.is_empty() || appended.is_empty(),
|
||||
}))
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DiscoverSortRequest {
|
||||
#[serde(default)]
|
||||
items: Vec<Value>,
|
||||
#[serde(default)]
|
||||
sort_by: Option<String>,
|
||||
#[serde(default)]
|
||||
ascending: bool,
|
||||
#[serde(default)]
|
||||
content_type_filter: Option<String>,
|
||||
#[serde(default)]
|
||||
genre_filter: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn discover_selection_plan_json(request_json: &str) -> Option<String> {
|
||||
let request: Value = serde_json::from_str(request_json).ok()?;
|
||||
let content_type = request
|
||||
.get("contentType")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("movie");
|
||||
let catalogs = request
|
||||
.get("catalogs")
|
||||
.and_then(Value::as_array)?
|
||||
.iter()
|
||||
.filter(|catalog| catalog.get("type").and_then(Value::as_str) == Some(content_type))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let requested_key = request.get("selectedCatalogKey").and_then(Value::as_str);
|
||||
let catalog = requested_key
|
||||
.and_then(|key| {
|
||||
catalogs
|
||||
.iter()
|
||||
.find(|catalog| catalog.get("key").and_then(Value::as_str) == Some(key))
|
||||
})
|
||||
.or_else(|| catalogs.first())
|
||||
.cloned();
|
||||
let selected_key = catalog
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("key"))
|
||||
.and_then(Value::as_str);
|
||||
let extra = catalog
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("extras"))
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|values| values.first())
|
||||
.cloned();
|
||||
let extra_options_contains = |value: &str| {
|
||||
extra
|
||||
.as_ref()
|
||||
.and_then(|extra| extra.get("options"))
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|options| options.iter().any(|option| option.as_str() == Some(value)))
|
||||
};
|
||||
let extra_required = extra
|
||||
.as_ref()
|
||||
.and_then(|extra| extra.get("isRequired"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let requested_extra = request.get("extraValue").and_then(Value::as_str);
|
||||
let extra_value = requested_extra
|
||||
.filter(|value| extra_options_contains(value))
|
||||
.or_else(|| {
|
||||
extra
|
||||
.as_ref()
|
||||
.and_then(|extra| extra.get("default"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| extra_required && extra_options_contains(value))
|
||||
});
|
||||
let extra_name = extra
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("name"))
|
||||
.and_then(Value::as_str);
|
||||
let key = format!(
|
||||
"{}|{}|{}",
|
||||
selected_key.unwrap_or(""),
|
||||
extra_name.unwrap_or(""),
|
||||
extra_value.unwrap_or("")
|
||||
);
|
||||
serde_json::to_string(&json!({"catalogs": catalogs, "selectedCatalogKey": selected_key, "selectedCatalog": catalog, "selectedExtra": extra, "extraValue": extra_value, "key": key})).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn discover_sort_plan_json(request_json: &str) -> Option<String> {
|
||||
let request = serde_json::from_str::<DiscoverSortRequest>(request_json).ok()?;
|
||||
let content_type = request.content_type_filter.as_deref().unwrap_or("");
|
||||
let genre = request.genre_filter.as_deref().unwrap_or("").to_lowercase();
|
||||
let sort_by = match request.sort_by.as_deref().unwrap_or("default") {
|
||||
"top" => "rating",
|
||||
"newest" => "year",
|
||||
other => other,
|
||||
};
|
||||
|
||||
let mut filtered: Vec<&Value> = request
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
let type_ok = content_type.is_empty()
|
||||
|| content_type == "anime"
|
||||
|| item
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|t| t == content_type);
|
||||
let genre_ok = genre.is_empty()
|
||||
|| item
|
||||
.get("genres")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|g| {
|
||||
g.iter()
|
||||
.any(|gv| gv.as_str().is_some_and(|s| s.to_lowercase() == genre))
|
||||
});
|
||||
type_ok && genre_ok
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut seen_ids: HashSet<&str> = HashSet::with_capacity(filtered.len());
|
||||
filtered.retain(|item| match item.get("id").and_then(Value::as_str) {
|
||||
Some(id) => seen_ids.insert(id),
|
||||
None => true,
|
||||
});
|
||||
|
||||
match sort_by {
|
||||
"year" => {
|
||||
filtered.sort_by(|a, b| {
|
||||
let ya = a
|
||||
.get("releaseInfo")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
let yb = b
|
||||
.get("releaseInfo")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
if request.ascending {
|
||||
ya.cmp(&yb)
|
||||
} else {
|
||||
yb.cmp(&ya)
|
||||
}
|
||||
});
|
||||
}
|
||||
"rating" => {
|
||||
filtered.sort_by(|a, b| {
|
||||
let ra = a.get("imdbRating").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let rb = b.get("imdbRating").and_then(Value::as_f64).unwrap_or(0.0);
|
||||
if request.ascending {
|
||||
ra.partial_cmp(&rb).unwrap_or(std::cmp::Ordering::Equal)
|
||||
} else {
|
||||
rb.partial_cmp(&ra).unwrap_or(std::cmp::Ordering::Equal)
|
||||
}
|
||||
});
|
||||
}
|
||||
"name" => {
|
||||
filtered.sort_by(|a, b| {
|
||||
let na = a.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
let nb = b.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
if request.ascending {
|
||||
na.cmp(nb)
|
||||
} else {
|
||||
nb.cmp(na)
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let total_count = filtered.len();
|
||||
filtered.truncate(DISCOVER_MAX_ITEMS);
|
||||
|
||||
serde_json::to_string(&json!({
|
||||
"items": filtered,
|
||||
"sortBy": sort_by,
|
||||
"ascending": request.ascending,
|
||||
"totalCount": total_count
|
||||
}))
|
||||
.ok()
|
||||
}
|
||||
98
src/search_plan/library_sort.rs
Normal file
98
src/search_plan/library_sort.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LibrarySortRequest {
|
||||
#[serde(default)]
|
||||
items: Vec<Value>,
|
||||
#[serde(default)]
|
||||
sort_by: Option<String>,
|
||||
#[serde(default)]
|
||||
ascending: bool,
|
||||
#[serde(default)]
|
||||
type_filter: Option<String>,
|
||||
#[serde(default)]
|
||||
status_filter: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn library_sort_plan_json(request_json: &str) -> Option<String> {
|
||||
let request = serde_json::from_str::<LibrarySortRequest>(request_json).ok()?;
|
||||
let type_filter = request.type_filter.as_deref().unwrap_or("").to_lowercase();
|
||||
let status_filter = request
|
||||
.status_filter
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let sort_by = request.sort_by.as_deref().unwrap_or("added");
|
||||
|
||||
let mut filtered: Vec<&Value> = request
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
let type_ok = type_filter.is_empty()
|
||||
|| item
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|t| t.to_lowercase() == type_filter);
|
||||
let status_ok = status_filter.is_empty()
|
||||
|| item
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|s| s.to_lowercase() == status_filter);
|
||||
type_ok && status_ok
|
||||
})
|
||||
.collect();
|
||||
|
||||
match sort_by {
|
||||
"name" => {
|
||||
filtered.sort_by(|a, b| {
|
||||
let na = a.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
let nb = b.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
if request.ascending {
|
||||
na.cmp(nb)
|
||||
} else {
|
||||
nb.cmp(na)
|
||||
}
|
||||
});
|
||||
}
|
||||
"year" => {
|
||||
filtered.sort_by(|a, b| {
|
||||
let ya = a
|
||||
.get("releaseInfo")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
let yb = b
|
||||
.get("releaseInfo")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.unwrap_or(0);
|
||||
if request.ascending {
|
||||
ya.cmp(&yb)
|
||||
} else {
|
||||
yb.cmp(&ya)
|
||||
}
|
||||
});
|
||||
}
|
||||
"progress" => {
|
||||
filtered.sort_by(|a, b| {
|
||||
let pa = a.get("timeOffset").and_then(Value::as_i64).unwrap_or(0);
|
||||
let pb = b.get("timeOffset").and_then(Value::as_i64).unwrap_or(0);
|
||||
if request.ascending {
|
||||
pa.cmp(&pb)
|
||||
} else {
|
||||
pb.cmp(&pa)
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
serde_json::to_string(&json!({
|
||||
"items": filtered,
|
||||
"sortBy": sort_by,
|
||||
"totalCount": filtered.len()
|
||||
}))
|
||||
.ok()
|
||||
}
|
||||
267
src/search_plan/search.rs
Normal file
267
src/search_plan/search.rs
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub(crate) fn search_suggestions_plan_json(request_json: &str) -> Option<String> {
|
||||
let request: Value = serde_json::from_str(request_json).ok()?;
|
||||
let needle = request.get("needle")?.as_str()?.trim().to_ascii_lowercase();
|
||||
if needle.len() < 2 {
|
||||
return Some("[]".to_string());
|
||||
}
|
||||
let limit = request.get("limit").and_then(Value::as_u64).unwrap_or(8) as usize;
|
||||
let mut seen_ids = HashSet::new();
|
||||
let mut seen_names = HashSet::new();
|
||||
let mut prefix = Vec::new();
|
||||
let mut contains = Vec::new();
|
||||
let values: Vec<&Value> =
|
||||
if let Some(categories) = request.get("categories").and_then(Value::as_array) {
|
||||
categories
|
||||
.iter()
|
||||
.flat_map(|category| {
|
||||
category
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
request
|
||||
.get("items")
|
||||
.and_then(Value::as_array)?
|
||||
.iter()
|
||||
.collect()
|
||||
};
|
||||
for item in values {
|
||||
let id = item.get("id").and_then(Value::as_str).unwrap_or("");
|
||||
let name = item
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if !name.contains(&needle) || !seen_ids.insert(id) || !seen_names.insert(name.clone()) {
|
||||
continue;
|
||||
}
|
||||
if name.starts_with(&needle) {
|
||||
prefix.push(item.clone());
|
||||
} else {
|
||||
contains.push(item.clone());
|
||||
}
|
||||
}
|
||||
prefix.extend(contains);
|
||||
prefix.truncate(limit);
|
||||
serde_json::to_string(&prefix).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn search_screen_plan_json(request_json: &str) -> Option<String> {
|
||||
let request: Value = serde_json::from_str(request_json).ok()?;
|
||||
let query = request
|
||||
.get("query")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let search_query = request
|
||||
.get("searchQuery")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let search_categories = request
|
||||
.get("searchCategories")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let cached_categories = request
|
||||
.get("cachedCategories")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let has_cache = request
|
||||
.get("hasCache")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let search_loading = request
|
||||
.get("searchLoading")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let matches = search_query == query;
|
||||
let raw = if matches && !search_categories.is_empty() {
|
||||
search_categories.clone()
|
||||
} else {
|
||||
cached_categories
|
||||
};
|
||||
let type_filter = request
|
||||
.get("typeFilter")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let categories = raw
|
||||
.into_iter()
|
||||
.filter_map(|mut category| {
|
||||
let items = category.get("items")?.as_array()?;
|
||||
let visible = items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
type_filter.is_empty()
|
||||
|| item.get("type").and_then(Value::as_str) == Some(type_filter)
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if visible.is_empty() {
|
||||
return None;
|
||||
}
|
||||
category["items"] = Value::Array(visible);
|
||||
Some(category)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let result_count = categories
|
||||
.iter()
|
||||
.filter_map(|category| category.get("items").and_then(Value::as_array))
|
||||
.map(Vec::len)
|
||||
.sum::<usize>();
|
||||
serde_json::to_string(&json!({
|
||||
"query": query,
|
||||
"queryEligible": query.chars().count() >= 2,
|
||||
"shouldDispatch": query.chars().count() >= 2 && !has_cache && !(matches && search_loading),
|
||||
"shouldCache": matches && !search_categories.is_empty(),
|
||||
"categories": categories,
|
||||
"resultCount": result_count,
|
||||
"categoryCount": categories.len(),
|
||||
"isLoading": search_loading && !has_cache,
|
||||
}))
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SearchGroupingRequest {
|
||||
#[serde(default)]
|
||||
results: Vec<Value>,
|
||||
#[serde(default)]
|
||||
query: String,
|
||||
}
|
||||
|
||||
pub(crate) fn search_result_grouping_json(request_json: &str) -> Option<String> {
|
||||
let request = serde_json::from_str::<SearchGroupingRequest>(request_json).ok()?;
|
||||
let mut movies: Vec<&Value> = Vec::new();
|
||||
let mut series: Vec<&Value> = Vec::new();
|
||||
let mut other: Vec<&Value> = Vec::new();
|
||||
for item in &request.results {
|
||||
match item.get("type").and_then(Value::as_str).unwrap_or("") {
|
||||
"movie" => movies.push(item),
|
||||
"series" | "anime" => series.push(item),
|
||||
_ => other.push(item),
|
||||
}
|
||||
}
|
||||
let mut groups = Vec::new();
|
||||
if !movies.is_empty() {
|
||||
groups.push(json!({ "type": "movie", "items": movies }));
|
||||
}
|
||||
if !series.is_empty() {
|
||||
groups.push(json!({ "type": "series", "items": series }));
|
||||
}
|
||||
if !other.is_empty() {
|
||||
groups.push(json!({ "type": "other", "items": other }));
|
||||
}
|
||||
serde_json::to_string(&json!({
|
||||
"groups": groups,
|
||||
"totalCount": request.results.len(),
|
||||
"query": request.query
|
||||
}))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Merges per-source search result batches (one per addon catalog request, plus TMDB
|
||||
/// builtin batches) into the flat results list and category descriptors the search
|
||||
/// screen renders — dropping empty sources rather than surfacing zero-result categories.
|
||||
pub(crate) fn merge_search_sources_json(sources_json: &str) -> Option<String> {
|
||||
let sources: Vec<Value> = serde_json::from_str(sources_json).ok()?;
|
||||
let mut categories: Vec<Value> = Vec::new();
|
||||
let mut results: Vec<Value> = Vec::new();
|
||||
for source in sources {
|
||||
let items = source
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if items.is_empty() {
|
||||
continue;
|
||||
}
|
||||
results.extend(items.iter().cloned());
|
||||
let name = source.get("name").cloned().unwrap_or(Value::Null);
|
||||
categories.push(json!({
|
||||
"id": source.get("id").cloned().unwrap_or(Value::Null),
|
||||
"name": name.clone(),
|
||||
"semanticName": source.get("semanticName").cloned().unwrap_or(name),
|
||||
"type": source.get("type").cloned().unwrap_or(Value::Null),
|
||||
"addonName": source.get("addonName").cloned().unwrap_or(Value::Null),
|
||||
"catalogId": source.get("catalogId").cloned().unwrap_or(Value::Null),
|
||||
"items": items,
|
||||
}));
|
||||
}
|
||||
serde_json::to_string(&json!({ "results": results, "categories": categories })).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn recent_searches_plan_json(request_json: &str) -> Option<String> {
|
||||
let request: Value = serde_json::from_str(request_json).ok()?;
|
||||
let operation = request
|
||||
.get("operation")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("normalize");
|
||||
let mut items = request
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
match operation {
|
||||
"add" => {
|
||||
let query = request
|
||||
.get("query")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if query.chars().count() >= 2 {
|
||||
items.retain(|item| {
|
||||
item.get("query")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(|value| !value.eq_ignore_ascii_case(query))
|
||||
});
|
||||
let mut item = json!({"query": query});
|
||||
if let Some(meta) = request.get("meta").filter(|value| !value.is_null()) {
|
||||
item["meta"] = meta.clone();
|
||||
}
|
||||
items.insert(0, item);
|
||||
}
|
||||
}
|
||||
"remove" => {
|
||||
let query = request.get("query").and_then(Value::as_str).unwrap_or("");
|
||||
items.retain(|item| item.get("query").and_then(Value::as_str) != Some(query));
|
||||
}
|
||||
"clear" => items.clear(),
|
||||
"normalize" => {}
|
||||
_ => return None,
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let normalized = items
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
let (query, meta) = match item {
|
||||
Value::String(query) => (query.trim().to_string(), None),
|
||||
Value::Object(object) => (
|
||||
object.get("query")?.as_str()?.trim().to_string(),
|
||||
object
|
||||
.get("meta")
|
||||
.filter(|value| value.is_object())
|
||||
.cloned(),
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
if query.is_empty() || !seen.insert(query.to_lowercase()) {
|
||||
return None;
|
||||
}
|
||||
Some(match meta {
|
||||
Some(meta) => json!({"query": query, "meta": meta}),
|
||||
None => json!({"query": query}),
|
||||
})
|
||||
})
|
||||
.take(8)
|
||||
.collect::<Vec<_>>();
|
||||
serde_json::to_string(&normalized).ok()
|
||||
}
|
||||
Loading…
Reference in a new issue