mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-08-18 13:05:54 +00:00
Support provider sync and calendar data plans
This commit is contained in:
parent
f2568a46c3
commit
fc0d5d56ba
7 changed files with 266 additions and 18 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
|||
/target/
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
docs/external/
|
||||
*.pdb
|
||||
stremio-addon-api/
|
||||
fluxa-streaming-engine/resources/ffmpeg/
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue