Add reusable calendar planning and profile-aware library commands

This commit is contained in:
KhooLy 2026-07-18 16:52:14 +03:00
parent 256a0aeaee
commit e68dcc405c
6 changed files with 223 additions and 12 deletions

View file

@ -21,6 +21,31 @@ struct CalendarItemInput {
episode_title: Option<String>,
#[serde(default)]
artwork_url: Option<String>,
#[serde(default)]
meta: Value,
#[serde(default)]
poster: Option<String>,
#[serde(default)]
episode_poster: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CandidatePlanRequest {
#[serde(default)]
groups: Vec<Vec<Value>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReleaseRowsRequest {
meta: Value,
#[serde(default)]
detail: Value,
#[serde(default)]
videos: Vec<Value>,
month_prefix: String,
movie_label: String,
}
#[derive(Debug, Deserialize)]
@ -86,12 +111,17 @@ pub(crate) fn calendar_content_plan_json(request_json: &str) -> Option<String> {
.items
.iter()
.filter(|item| {
let meta_id = if item.meta_id.trim().is_empty() {
item.meta.get("id").and_then(Value::as_str).unwrap_or("")
} else {
&item.meta_id
};
item.date_iso.starts_with(prefix)
&& !item.meta_id.trim().is_empty()
&& !meta_id.trim().is_empty()
&& seen.insert(format!(
"{}:{}:{}",
item.date_iso,
item.meta_id,
meta_id,
item.subtitle.as_deref().unwrap_or("")
))
})
@ -104,22 +134,159 @@ pub(crate) fn calendar_content_plan_json(request_json: &str) -> Option<String> {
let out: Vec<Value> = filtered
.iter()
.map(|item| {
let meta_id = if item.meta_id.trim().is_empty() {
item.meta.get("id").and_then(Value::as_str).unwrap_or("")
} else {
&item.meta_id
};
let meta_type = if item.meta_type.trim().is_empty() {
item.meta.get("type").and_then(Value::as_str).unwrap_or("")
} else {
&item.meta_type
};
json!({
"dateIso": item.date_iso,
"metaId": item.meta_id,
"metaType": item.meta_type,
"metaId": meta_id,
"metaType": meta_type,
"title": item.title,
"subtitle": item.subtitle,
"seasonNumber": item.season_number,
"episodeNumber": item.episode_number,
"episodeTitle": item.episode_title,
"artworkUrl": item.artwork_url
"artworkUrl": item.artwork_url,
"meta": item.meta,
"poster": item.poster,
"episodePoster": item.episode_poster
})
})
.collect();
serde_json::to_string(&out).ok()
}
pub(crate) fn calendar_candidate_plan_json(request_json: &str) -> Option<String> {
let request = serde_json::from_str::<CandidatePlanRequest>(request_json).ok()?;
let mut seen = std::collections::HashSet::new();
let candidates: Vec<Value> = request
.groups
.into_iter()
.flatten()
.filter(|item| {
let id = item.get("id").and_then(Value::as_str).unwrap_or("").trim();
let content_type = item
.get("type")
.and_then(Value::as_str)
.unwrap_or("")
.trim();
!id.is_empty()
&& content_type != "catalog_folder"
&& seen.insert(format!("{}:{}", content_type.to_ascii_lowercase(), id))
})
.collect();
serde_json::to_string(&candidates).ok()
}
pub(crate) fn calendar_release_rows_json(request_json: &str) -> Option<String> {
let request = serde_json::from_str::<ReleaseRowsRequest>(request_json).ok()?;
let meta = &request.meta;
let content_type = meta
.get("type")
.and_then(Value::as_str)
.unwrap_or("")
.to_ascii_lowercase();
let meta_id = meta.get("id").and_then(Value::as_str).unwrap_or("");
let title = meta.get("name").and_then(Value::as_str).unwrap_or(meta_id);
if ["movie", "film"].contains(&content_type.as_str()) {
let date_iso = meta
.get("released")
.and_then(Value::as_str)
.and_then(|value| value.get(..10))?;
if !date_iso.starts_with(&request.month_prefix) {
return serde_json::to_string(&json!([])).ok();
}
let poster = usable_artwork(meta.get("poster").and_then(Value::as_str));
return serde_json::to_string(&json!([{
"dateIso": date_iso,
"meta": meta,
"title": title,
"subtitle": request.movie_label,
"poster": poster
}]))
.ok();
}
if !["series", "tv", "show", "anime"].contains(&content_type.as_str()) {
return serde_json::to_string(&json!([])).ok();
}
let detail = &request.detail;
let fallback_artwork = [
meta.get("poster").and_then(Value::as_str),
detail.get("poster").and_then(Value::as_str),
meta.get("continueWatchingPoster").and_then(Value::as_str),
meta.get("background").and_then(Value::as_str),
meta.get("continueWatchingBackground")
.and_then(Value::as_str),
detail.get("background").and_then(Value::as_str),
]
.into_iter()
.flatten()
.find_map(|url| usable_artwork(Some(url)));
let rows: Vec<Value> = request
.videos
.iter()
.filter_map(|video| {
let date_iso = video
.get("released")
.and_then(Value::as_str)
.and_then(|value| value.get(..10))?;
if !date_iso.starts_with(&request.month_prefix) {
return None;
}
let season = video.get("season").and_then(Value::as_i64);
let episode = video
.get("number")
.or_else(|| video.get("episode"))
.and_then(Value::as_i64);
let episode_code = match (season, episode) {
(Some(s), Some(e)) => Some(format!("S{s}:E{e}")),
(Some(s), None) => Some(format!("S{s}")),
(None, Some(e)) => Some(format!("E{e}")),
_ => None,
};
let episode_title = video
.get("name")
.or_else(|| video.get("title"))
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty());
let subtitle = [episode_code.as_deref(), episode_title]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" ");
Some(json!({
"dateIso": date_iso,
"meta": meta,
"title": title,
"subtitle": subtitle,
"poster": fallback_artwork,
"episodePoster": fallback_artwork,
"seasonNumber": season,
"episodeNumber": episode,
"episodeTitle": episode_title
}))
})
.collect();
serde_json::to_string(&rows).ok()
}
fn usable_artwork(url: Option<&str>) -> Option<&str> {
url.filter(|value| {
let normalized = value.trim().to_ascii_lowercase();
!normalized.is_empty()
&& normalized != "null"
&& !normalized.contains("default-poster")
&& !normalized.contains("placeholder")
})
}
pub(crate) fn calendar_season_candidates_json(request_json: &str) -> Option<String> {
let request = serde_json::from_str::<SeasonCandidatesRequest>(request_json).ok()?;
let seasons_count = request.seasons_count.unwrap_or(1).max(1);
@ -419,6 +586,42 @@ mod tests {
use super::*;
use serde_json::Value;
#[test]
fn candidate_plan_merges_groups_and_deduplicates_content() {
let result: Value = serde_json::from_str(
&calendar_candidate_plan_json(
r#"{"groups":[[{"id":"tt1","type":"series","name":"Library"}],[{"id":"tt1","type":"series","name":"Progress"},{"id":"tt2","type":"anime","name":"Provider"}]]}"#,
)
.unwrap(),
)
.unwrap();
assert_eq!(result.as_array().unwrap().len(), 2);
assert_eq!(result[0]["name"], json!("Library"));
assert_eq!(result[1]["id"], json!("tt2"));
}
#[test]
fn release_rows_build_series_episodes_and_movie_releases() {
let series: Value = serde_json::from_str(
&calendar_release_rows_json(
r#"{"meta":{"id":"tt1","type":"tv","name":"Show","poster":"poster.jpg"},"videos":[{"released":"2026-07-20T00:00:00Z","season":2,"number":1,"name":"Premiere"}],"monthPrefix":"2026-07","movieLabel":"Movie"}"#,
)
.unwrap(),
)
.unwrap();
assert_eq!(series[0]["subtitle"], json!("S2:E1 Premiere"));
assert_eq!(series[0]["meta"]["id"], json!("tt1"));
let movie: Value = serde_json::from_str(
&calendar_release_rows_json(
r#"{"meta":{"id":"tt2","type":"film","name":"Film","released":"2026-07-21"},"videos":[],"monthPrefix":"2026-07","movieLabel":"Movie"}"#,
)
.unwrap(),
)
.unwrap();
assert_eq!(movie[0]["subtitle"], json!("Movie"));
}
#[test]
fn content_plan_filters_deduplicates_and_sorts_by_date_then_title() {
let result: Value = serde_json::from_str(

View file

@ -848,6 +848,8 @@ fn route_content_identity(method: &str, args_json: &str) -> Outcome {
fn route_calendar(method: &str, args_json: &str) -> Outcome {
match method {
"calendarCandidatePlan" => opt_json(calendar_plan::calendar_candidate_plan_json(args_json)),
"calendarReleaseRows" => opt_json(calendar_plan::calendar_release_rows_json(args_json)),
"calendarContentPlan" => opt_json(calendar_plan::calendar_content_plan_json(args_json)),
"calendarSeasonCandidates" => opt_json(calendar_plan::calendar_season_candidates_json(
args_json,
@ -1669,7 +1671,7 @@ fn route_addon_store(method: &str, args_json: &str) -> Outcome {
)))
}
// args_json IS the profile object
"profileLocalAddonsKey" => opt_json(addon_store::profile_local_addons_key_json(
"profileLocalAddonsKey" => opt_str(addon_store::profile_local_addons_key_json(
args_json,
)),
"sanitizeProfile" => {

View file

@ -159,7 +159,10 @@ pub(super) enum AppAction {
#[serde(rename = "libraryHydrateRequested")]
LibraryHydrateRequested { profile_id: Option<String> },
#[serde(rename = "toggleWatchlistRequested")]
ToggleWatchlistRequested { item: Value },
ToggleWatchlistRequested {
item: Value,
profile: Option<Value>,
},
#[serde(rename = "toggleLibraryStatusRequested")]
ToggleLibraryStatusRequested { list: String, item: Value },
#[serde(rename = "setFeedbackRequested")]

View file

@ -157,9 +157,10 @@ pub(super) fn dispatch_hydrate(
pub(super) fn dispatch_toggle_watchlist(
engine: &mut HeadlessEngine,
item: Value,
profile: Option<Value>,
) -> Vec<EffectEnvelope> {
let generation = engine.bump_generation(GenerationKey::Library);
let profile_id = active_profile_id(&engine.state, &Value::Null);
let profile_id = active_profile_id(&engine.state, &profile.unwrap_or(Value::Null));
let command = ToggleWatchlistCommand {
kind: "toggleWatchlist",
item,

View file

@ -350,8 +350,8 @@ impl HeadlessEngine {
AppAction::LibraryHydrateRequested { profile_id } => {
library::dispatch_hydrate(self, profile_id)
}
AppAction::ToggleWatchlistRequested { item } => {
library::dispatch_toggle_watchlist(self, item)
AppAction::ToggleWatchlistRequested { item, profile } => {
library::dispatch_toggle_watchlist(self, item, profile)
}
AppAction::ToggleLibraryStatusRequested { list, item } => {
library::dispatch_toggle_status(self, list, item)
@ -1243,14 +1243,14 @@ mod tests {
let requested: Value = serde_json::from_str(
&headless_engine_dispatch_json(
handle,
r#"{"type":"toggleWatchlistRequested","item":{"id":"tt1","name":"Movie","type":"movie"}}"#,
r#"{"type":"toggleWatchlistRequested","profile":{"id":"p2"},"item":{"id":"tt1","name":"Movie","type":"movie"}}"#,
)
.unwrap(),
)
.unwrap();
assert_eq!(requested["effects"][0]["type"], "writeLibraryCommand");
assert_eq!(requested["effects"][0]["payload"]["profileId"], "p1");
assert_eq!(requested["effects"][0]["payload"]["profileId"], "p2");
assert_eq!(
requested["effects"][0]["payload"]["command"]["type"],
"toggleWatchlist"

View file

@ -19,11 +19,13 @@ buildResourceUrl
buildTraktIds
cacheEntryPolicy
cacheTrimPolicy
calendarCandidatePlan
calendarContentPlan
calendarItemMatchesMonth
calendarItemsFromMeta
calendarNotificationContent
calendarReleaseDetection
calendarReleaseRows
calendarSeasonCandidates
calendarWidgetRows
canPrefetchNextEpisode