Support provider sync and calendar data plans

This commit is contained in:
KhooLy 2026-07-26 17:15:18 +03:00
parent f2568a46c3
commit fc0d5d56ba
7 changed files with 266 additions and 18 deletions

1
.gitignore vendored
View file

@ -1,6 +1,7 @@
/target/
CLAUDE.md
AGENTS.md
docs/external/
*.pdb
stremio-addon-api/
fluxa-streaming-engine/resources/ffmpeg/

View file

@ -4,17 +4,18 @@ use serde_json::{json, Value};
pub(crate) fn calendar_visibility_plan_json(request_json: &str) -> Option<String> {
let request: Value = serde_json::from_str(request_json).ok()?;
let items = request.get("items")?.as_array()?;
if request.get("showCompleted").and_then(Value::as_bool) == Some(true) {
return serde_json::to_string(items).ok();
}
let today_iso = request
.get("todayIso")
.and_then(Value::as_str)
.unwrap_or("");
let completed = request.get("completedItems")?.as_array()?;
let show_completed = request.get("showCompleted").and_then(Value::as_bool) == Some(true);
let visible: Vec<&Value> = items
.iter()
.filter(|item| {
if show_completed {
return true;
}
if !today_iso.is_empty()
&& item
.get("dateIso")
@ -46,7 +47,68 @@ pub(crate) fn calendar_visibility_plan_json(request_json: &str) -> Option<String
})
})
.collect();
serde_json::to_string(&visible).ok()
let mut seen = std::collections::HashMap::new();
let mut unique = Vec::new();
for item in visible {
let key = calendar_item_identity(item);
let score = calendar_item_detail_score(item);
if let Some((index, current_score)) = seen.get_mut(&key) {
if score > *current_score {
unique[*index] = item;
*current_score = score;
}
} else {
seen.insert(key, (unique.len(), score));
unique.push(item);
}
}
serde_json::to_string(&unique).ok()
}
fn calendar_item_identity(item: &Value) -> String {
let date = item
.get("dateIso")
.and_then(Value::as_str)
.map(|value| value.get(..10).unwrap_or(value))
.unwrap_or("");
let content = item
.get("title")
.and_then(Value::as_str)
.or_else(|| item.get("name").and_then(Value::as_str))
.or_else(|| {
["contentId", "seriesId"]
.iter()
.find_map(|key| item.get(*key).and_then(Value::as_str))
})
.or_else(|| item.get("id").and_then(Value::as_str))
.unwrap_or("");
let season = ["seasonNumber", "season"]
.iter()
.find_map(|key| item.get(*key).and_then(Value::as_i64))
.unwrap_or_default();
let episode = ["episodeNumber", "episode", "number"]
.iter()
.find_map(|key| item.get(*key).and_then(Value::as_i64))
.unwrap_or_default();
format!("{date}:{content}:{season}:{episode}")
}
fn calendar_item_detail_score(item: &Value) -> usize {
[
"poster",
"seriesPoster",
"episodePoster",
"episodeTitle",
"airTime",
"releaseTime",
]
.iter()
.filter(|key| {
item.get(**key)
.and_then(Value::as_str)
.is_some_and(|value| !value.is_empty())
})
.count()
}
#[derive(Debug, Deserialize)]
@ -590,6 +652,9 @@ pub(crate) fn calendar_items_from_meta_json(meta_json: &str, month_prefix: &str)
"subtitle": subtitle,
"dateIso": date_iso,
"poster": poster,
"contentId": meta_id,
"seriesId": meta_id,
"metaType": meta.get("type"),
}));
}
serde_json::to_string(&items).ok()
@ -724,6 +789,41 @@ mod tests {
assert_eq!(items[0]["dateIso"], "2026-07-27T03:00:00Z");
}
#[test]
fn visibility_deduplicates_provider_copies_of_the_same_episode() {
let request = json!({
"items": [
{"id":"tt2861424:episode-10:2026-07-27","title":"Rick and Morty","dateIso":"2026-07-27T03:00:00Z","seasonNumber":9,"episodeNumber":10,"poster":"poster.jpg"},
{"contentId":"tt2861424","title":"Rick and Morty","dateIso":"2026-07-27T00:00:00Z","seasonNumber":9,"episodeNumber":10,"episodeTitle":"Field of Dreams"},
{"contentId":"tt2861424","title":"Rick and Morty","dateIso":"2026-07-27T00:00:00Z","seasonNumber":9,"episodeNumber":10,"poster":"poster.jpg","episodeTitle":"Field of Dreams"}
],
"completedItems": [],
"showCompleted": true,
"todayIso": "2026-07-26"
});
let result: Value =
serde_json::from_str(&calendar_visibility_plan_json(&request.to_string()).unwrap())
.unwrap();
let items = result.as_array().unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0]["episodeTitle"], "Field of Dreams");
}
#[test]
fn items_from_meta_keep_calendar_identity_for_deduplication() {
let result: Value = serde_json::from_str(
&calendar_items_from_meta_json(
r#"{"id":"tt2861424","type":"series","name":"Rick and Morty","videos":[{"released":"2026-08-02T00:00:00Z","season":9,"episode":11,"name":"Episode"}]}"#,
"2026-08",
)
.unwrap(),
)
.unwrap();
assert_eq!(result[0]["contentId"], "tt2861424");
assert_eq!(result[0]["seriesId"], "tt2861424");
assert_eq!(result[0]["metaType"], "series");
}
#[test]
fn candidate_plan_merges_groups_and_deduplicates_content() {
let result: Value = serde_json::from_str(

View file

@ -165,6 +165,72 @@ pub(crate) fn provider_calendar_items_json(args_json: &str) -> Option<String> {
}
return serde_json::to_string(&items).ok();
}
if provider == "simkl" && args.get("shows").and_then(|value| value.get("calendar")).is_some() {
let allowed_content_ids: std::collections::HashSet<&str> = args
.get("allowedContentIds")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect();
for (calendar, metadata, is_movie) in [
(
args.get("shows").and_then(|value| value.get("calendar")).and_then(Value::as_array),
args.get("shows").and_then(|value| value.get("metadata")).and_then(Value::as_object),
false,
),
(
args.get("movies").and_then(|value| value.get("calendar")).and_then(Value::as_array),
args.get("movies").and_then(|value| value.get("metadata")).and_then(Value::as_object),
true,
),
] {
for entry in calendar.into_iter().flatten() {
let Some(simkl_id) = entry.get("simkl_id") else { continue };
let simkl_key = simkl_id.as_str().map(str::to_string).or_else(|| simkl_id.as_i64().map(|value| value.to_string()));
let Some(media) = simkl_key.as_deref().and_then(|key| metadata.and_then(|value| value.get(key))) else { continue };
let ids = media.get("ids").unwrap_or(&Value::Null);
let content_id = ids.get("imdb").and_then(Value::as_str).map(str::to_string).or_else(|| {
ids.get("tmdb").and_then(Value::as_i64).map(|id| format!("tmdb:{id}")).or_else(|| {
ids.get("tmdb").and_then(Value::as_str).filter(|id| !id.is_empty()).map(|id| format!("tmdb:{id}"))
})
});
let Some(content_id) = content_id else { continue };
if !allowed_content_ids.contains(content_id.as_str()) {
continue;
}
let Some(date) = entry.get("date").and_then(Value::as_str) else { continue };
if is_movie {
items.push(json!({
"id": content_id,
"title": media.get("title"),
"dateIso": date,
"contentId": content_id,
"metaType": "movie",
"poster": media.get("poster"),
}));
continue;
}
let episode = entry.get("episode").unwrap_or(&Value::Null);
let season = episode.get("season").and_then(Value::as_i64);
let number = episode.get("episode").and_then(Value::as_i64);
items.push(json!({
"id": format!("{content_id}:{}:{}", season.unwrap_or_default(), number.unwrap_or_default()),
"title": media.get("title"),
"episodeTitle": episode.get("title"),
"seasonNumber": season,
"episodeNumber": number,
"dateIso": date,
"contentId": content_id,
"seriesId": content_id,
"metaType": "series",
"poster": media.get("poster"),
"seriesPoster": media.get("poster"),
}));
}
}
return serde_json::to_string(&items).ok();
}
for entry in shows.into_iter().flatten() {
let Some(show) = entry.get("show") else {
continue;
@ -204,6 +270,8 @@ pub(crate) fn provider_calendar_items_json(args_json: &str) -> Option<String> {
.or_else(|| episode.get("episode"))
.or_else(|| episode.get("episode_number"))
.and_then(Value::as_i64);
let episode_poster = provider_image_url(episode, "screenshot");
let series_poster = provider_image_url(show, "poster");
items.push(json!({
"id": format!("{series_id}:{}:{}", season.unwrap_or_default(), number.unwrap_or_default()),
"title": show.get("title"),
@ -214,6 +282,9 @@ pub(crate) fn provider_calendar_items_json(args_json: &str) -> Option<String> {
"contentId": series_id,
"seriesId": series_id,
"metaType": "series",
"poster": episode_poster.as_ref().or(series_poster.as_ref()),
"episodePoster": episode_poster,
"seriesPoster": series_poster,
}));
}
for entry in movies.into_iter().flatten() {
@ -245,11 +316,32 @@ pub(crate) fn provider_calendar_items_json(args_json: &str) -> Option<String> {
else {
continue;
};
items.push(json!({"id": content_id, "title": movie.get("title"), "dateIso": date, "contentId": content_id}));
let poster = provider_image_url(movie, "poster");
items.push(json!({
"id": content_id,
"title": movie.get("title"),
"dateIso": date,
"contentId": content_id,
"metaType": "movie",
"poster": poster,
}));
}
serde_json::to_string(&items).ok()
}
fn provider_image_url(media: &Value, image_type: &str) -> Option<String> {
let image = media
.get("images")?
.get(image_type)
.and_then(|value| value.as_array().and_then(|images| images.first()).or(Some(value)))
.and_then(Value::as_str)?;
if image.starts_with("https://") || image.starts_with("http://") {
Some(image.to_string())
} else {
Some(format!("https://{image}"))
}
}
pub(crate) fn provider_pagination_plan_json(args_json: &str) -> Option<String> {
let args: Value = serde_json::from_str(args_json).ok()?;
let base_url = args.get("baseUrl")?.as_str()?;
@ -559,8 +651,12 @@ pub(crate) fn trakt_bearer(token: &str) -> String {
}
pub(crate) fn trakt_scrobble_url(action: &str) -> Option<String> {
matches!(action.trim(), "start" | "pause" | "stop")
.then(|| format!("{TRAKT_API_BASE_URL}/scrobble/{}", action.trim()))
match action.trim() {
"start" => Some(format!("{TRAKT_API_BASE_URL}/scrobble/start")),
"pause" => Some(format!("{TRAKT_API_BASE_URL}/scrobble/pause")),
"stop" => Some(format!("{TRAKT_API_BASE_URL}/scrobble/stop")),
_ => None,
}
}
pub(crate) fn trakt_playback_url(content_type: Option<&str>) -> Option<String> {
@ -1227,12 +1323,14 @@ mod tests {
"first_aired": "2026-07-27T03:00:00Z",
"show": {
"title": "Rick and Morty",
"ids": {"imdb": "tt2861424"}
"ids": {"imdb": "tt2861424"},
"images": {"poster": ["walter-r2.trakt.tv/images/shows/poster.webp"]}
},
"episode": {
"season": 9,
"number": 10,
"title": "Episode Title"
"title": "Episode Title",
"images": {"screenshot": ["walter-r2.trakt.tv/images/episodes/screenshot.webp"]}
}
}],
"movies": []
@ -1243,6 +1341,7 @@ mod tests {
assert_eq!(result[0]["seasonNumber"], 9);
assert_eq!(result[0]["episodeNumber"], 10);
assert_eq!(result[0]["metaType"], "series");
assert_eq!(result[0]["episodePoster"], "https://walter-r2.trakt.tv/images/episodes/screenshot.webp");
}
#[test]
@ -1270,6 +1369,26 @@ mod tests {
assert_eq!(result[0]["episodeNumber"], 10);
}
#[test]
fn simkl_calendar_items_accept_v2_cdn_payloads() {
let request = json!({
"provider": "simkl",
"shows": {
"calendar": [{"simkl_id": 3437, "date": "2026-07-27T04:00:00Z", "episode": {"season": 15, "episode": 10, "title": "Propane Recall"}}],
"metadata": {"3437": {"title": "King of the Hill", "poster": "https://example.test/poster.jpg", "ids": {"imdb": "tt0118375"}}}
},
"movies": {"calendar": [], "metadata": {}},
"allowedContentIds": ["tt0118375"]
});
let result: Value =
serde_json::from_str(&provider_calendar_items_json(&request.to_string()).unwrap())
.unwrap();
assert_eq!(result[0]["contentId"], "tt0118375");
assert_eq!(result[0]["seasonNumber"], 15);
assert_eq!(result[0]["episodeNumber"], 10);
assert_eq!(result[0]["poster"], "https://example.test/poster.jpg");
}
#[test]
fn replace_external_continue_watching_sorts_by_saved_at_descending() {
let items = json!([
@ -1497,6 +1616,7 @@ mod tests {
None,
trakt_input["timePosSec"].as_f64().unwrap(),
trakt_input["durationSec"].as_f64().unwrap(),
None,
)
.unwrap(),
)

View file

@ -1403,6 +1403,7 @@ fn route_player_flow(method: &str, args_json: &str) -> Outcome {
fn route_player_scrobble(method: &str, args_json: &str) -> Outcome {
match method {
"playerScrobbleLifecycleAction" => opt_json(player_scrobble::lifecycle_action_json(args_json)),
"scrobbleMediaContext" => opt_json(player_scrobble::scrobble_media_context_json(args_json)),
"playerProgressPercent" => {
let args = object(args_json)?;

View file

@ -179,6 +179,7 @@ pub(super) fn route_external_sync_trakt(method: &str, args_json: &str) -> Outcom
ep_number,
time_pos,
duration,
args.get("action").and_then(Value::as_str),
))
}
"replaceExternalContinueWatching" => {

View file

@ -13,6 +13,7 @@ pub(crate) fn oauth_request_plan_json(request_json: &str) -> Option<String> {
.and_then(Value::as_str)
.unwrap_or("");
let code = request.get("code").and_then(Value::as_str).unwrap_or("");
let code_verifier = request.get("codeVerifier").and_then(Value::as_str).unwrap_or("");
let refresh_token = request
.get("refreshToken")
.and_then(Value::as_str)
@ -38,13 +39,9 @@ pub(crate) fn oauth_request_plan_json(request_json: &str) -> Option<String> {
"https://anilist.co/api/v2/oauth/token",
json!({"grant_type": "authorization_code", "client_id": client_id, "client_secret": client_secret, "redirect_uri": "fluxa://oauth/anilist", "code": code}),
),
("anilist", "refresh") => (
"https://anilist.co/api/v2/oauth/token",
json!({"grant_type": "refresh_token", "client_id": client_id, "client_secret": client_secret, "refresh_token": refresh_token}),
),
("simkl", "exchange") => (
"https://api.simkl.com/oauth/token",
json!({"code": code, "client_id": client_id, "client_secret": client_secret, "redirect_uri": "fluxa://oauth/simkl", "grant_type": "authorization_code"}),
json!({"code": code, "client_id": client_id, "code_verifier": code_verifier, "redirect_uri": "fluxa://oauth/simkl", "grant_type": "authorization_code"}),
),
_ => return None,
};

View file

@ -94,6 +94,23 @@ fn has_token(token: Option<&str>) -> bool {
token.is_some_and(|value| !value.trim().is_empty())
}
pub(crate) fn lifecycle_action_json(args_json: &str) -> Option<String> {
let args: serde_json::Value = serde_json::from_str(args_json).ok()?;
let event = args.get("event")?.as_str()?;
let has_started = args.get("hasStarted").and_then(serde_json::Value::as_bool).unwrap_or(false);
let has_paused = args.get("hasPaused").and_then(serde_json::Value::as_bool).unwrap_or(false);
let has_stopped = args.get("hasStopped").and_then(serde_json::Value::as_bool).unwrap_or(false);
let progress = args.get("progress").and_then(serde_json::Value::as_f64).unwrap_or(0.0) as f32;
let token = args.get("token").and_then(serde_json::Value::as_str);
let action = match event {
"start" if should_send_start(token, true, !has_paused && has_started, progress) => "start",
"pause" if should_queue_pause(token, true, has_started, has_stopped) => "pause",
"stop" if has_started && !has_stopped => "stop",
_ => return None,
};
serde_json::to_string(&serde_json::json!({ "action": action })).ok()
}
pub(crate) fn scrobble_media_context_json(args_json: &str) -> Option<String> {
let args: serde_json::Value = serde_json::from_str(args_json).ok()?;
let meta = args.get("meta")?;
@ -141,16 +158,19 @@ pub(crate) fn trakt_scrobble_plan_json(
ep_number: Option<i64>,
time_pos_sec: f64,
duration_sec: f64,
requested_action: Option<&str>,
) -> Option<String> {
let ids: serde_json::Value = serde_json::from_str(ids_json).ok()?;
if duration_sec <= 0.0 {
return None;
}
let progress = ((time_pos_sec / duration_sec) * 100.0).clamp(0.0, 100.0);
let action = if progress as f32 >= SCROBBLE_STOP_PROGRESS_PERCENT {
"stop"
} else {
"pause"
let action = match requested_action {
Some("start") => "start",
Some("pause") => "pause",
Some("stop") => "stop",
_ if progress as f32 >= SCROBBLE_STOP_PROGRESS_PERCENT => "stop",
_ => "pause",
};
let body = if is_episode {
serde_json::json!({
@ -209,6 +229,14 @@ mod tests {
assert!(should_send_start(Some("token"), true, false, 0.3));
}
#[test]
fn lifecycle_actions_follow_start_pause_stop_order() {
assert_eq!(lifecycle_action_json(r#"{"event":"start","token":"token","hasStarted":false,"hasStopped":false,"progress":1}"#).as_deref(), Some(r#"{"action":"start"}"#));
assert_eq!(lifecycle_action_json(r#"{"event":"start","token":"token","hasStarted":true,"hasPaused":true,"hasStopped":false,"progress":1}"#).as_deref(), Some(r#"{"action":"start"}"#));
assert_eq!(lifecycle_action_json(r#"{"event":"pause","token":"token","hasStarted":true,"hasStopped":false,"progress":1}"#).as_deref(), Some(r#"{"action":"pause"}"#));
assert_eq!(lifecycle_action_json(r#"{"event":"stop","token":"token","hasStarted":true,"hasStopped":false,"progress":1}"#).as_deref(), Some(r#"{"action":"stop"}"#));
}
#[test]
fn pause_stop_and_save_thresholds_match_platform_contract() {
assert!(!should_mark_stopped(false, 79.9));