centralize desktop domain policy in core

This commit is contained in:
KhooLy 2026-07-18 20:17:51 +03:00
parent 25b3ca73a0
commit d6c3bd72be
4 changed files with 260 additions and 1 deletions

View file

@ -1,6 +1,9 @@
use crate::stream_policy;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
use crate::{cast_protocol, headless_engine, offline_download, player_policy, subtitle_sync};
use crate::{
cast_protocol, desktop_playback, headless_engine, library_persistence, offline_download,
player_policy, subtitle_sync,
};
pub struct FluxaCore;
@ -60,6 +63,45 @@ impl FluxaCore {
})
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn should_play_next_episode(has_next_episode: bool, auto_play: bool) -> bool {
guard(false, || {
desktop_playback::should_play_next_episode(has_next_episode, auto_play)
})
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn chapter_skip_segments_json(chapters_json: &str) -> String {
guard("[]".to_string(), || {
desktop_playback::chapter_skip_segments_json(chapters_json)
})
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn library_progress_entries_json(document_json: &str) -> String {
guard("[]".to_string(), || library_persistence::progress_entries_json(document_json))
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn library_items_json(document_json: &str) -> String {
guard("[]".to_string(), || library_persistence::library_items_json(document_json))
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn library_watched_video_ids_json(document_json: &str) -> String {
guard("[]".to_string(), || library_persistence::watched_video_ids_json(document_json))
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn library_last_watched_entries_json(document_json: &str) -> String {
guard("[]".to_string(), || library_persistence::last_watched_entries_json(document_json))
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn library_continue_watching_entries_json(document_json: &str) -> String {
guard("[]".to_string(), || library_persistence::continue_watching_entries_json(document_json))
}
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
pub fn subtitle_sync_estimate_json(request_json: &str) -> Option<String> {
guard(None, || {

90
src/desktop_playback.rs Normal file
View file

@ -0,0 +1,90 @@
use serde::Deserialize;
use serde_json::Value;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Chapter {
title: String,
start_time: i64,
}
pub(crate) fn should_play_next_episode(has_next_episode: bool, auto_play: bool) -> bool {
has_next_episode && auto_play
}
pub(crate) fn chapter_skip_segments_json(chapters_json: &str) -> String {
let chapters: Vec<Chapter> = match serde_json::from_str(chapters_json) {
Ok(chapters) => chapters,
Err(_) => return "[]".to_string(),
};
let segments = chapters
.iter()
.enumerate()
.filter_map(|(index, chapter)| {
let segment_type = classify_chapter(&chapter.title)?;
let end_time = chapters.get(index + 1)?.start_time;
(end_time > chapter.start_time).then(|| {
serde_json::json!({
"type": segment_type,
"startTime": chapter.start_time,
"endTime": end_time,
})
})
})
.collect::<Vec<Value>>();
serde_json::to_string(&segments).unwrap_or_else(|_| "[]".to_string())
}
fn classify_chapter(title: &str) -> Option<&'static str> {
let normalized = title.trim().to_lowercase();
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"),
"recap" | "previously" | "previously on" | "cold open" => return Some("recap"),
_ => {}
}
if normalized.starts_with("op ")
|| normalized.starts_with("opening ")
|| normalized.contains("intro")
|| normalized.contains("opening")
{
return Some("intro");
}
if normalized.starts_with("ed ")
|| normalized.starts_with("ending ")
|| normalized.contains("ending")
|| normalized.contains("outro")
|| normalized.contains("credits")
{
return Some("outro");
}
if normalized.contains("recap") || normalized.contains("previously") {
return Some("recap");
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn next_episode_requires_both_a_successor_and_autoplay() {
assert!(should_play_next_episode(true, true));
assert!(!should_play_next_episode(true, false));
assert!(!should_play_next_episode(false, true));
}
#[test]
fn derives_skip_segments_from_named_chapters() {
let result = chapter_skip_segments_json(
r#"[{"title":"Opening Theme","startTime":0},{"title":"Episode","startTime":90000},{"title":"Credits","startTime":120000},{"title":"End","startTime":140000}]"#,
);
let value: Value = serde_json::from_str(&result).unwrap();
assert_eq!(value[0]["type"], "intro");
assert_eq!(value[0]["endTime"], 90000);
assert_eq!(value[1]["type"], "outro");
}
}

View file

@ -45,6 +45,8 @@ mod core_error;
mod data_policy;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod discovery_plan;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod desktop_playback;
#[cfg(feature = "native")]
mod dolby_vision_rpu;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
@ -59,6 +61,8 @@ mod home_ranking;
mod intro_segments;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod library_state;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod library_persistence;
pub mod log_sink;
#[cfg(any(feature = "full-api", not(feature = "streaming-shared")))]
mod nuvio_sync;

123
src/library_persistence.rs Normal file
View file

@ -0,0 +1,123 @@
use serde::Serialize;
use serde_json::Value;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct LibraryItem<'a> {
media_id: &'a str,
status: &'static str,
value: &'a Value,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct KeyedValue<'a> {
key: &'a str,
value: &'a Value,
}
pub(crate) fn progress_entries_json(document_json: &str) -> String {
object_entries(document_json, "progress")
}
pub(crate) fn library_items_json(document_json: &str) -> String {
let document: Value = match serde_json::from_str(document_json) {
Ok(document) => document,
Err(_) => return "[]".to_string(),
};
let mut entries = Vec::new();
for status in ["watchlist", "completed", "dropped"] {
for item in document
.get(status)
.and_then(Value::as_array)
.into_iter()
.flatten()
{
if let Some(media_id) = item.get("id").and_then(Value::as_str) {
entries.push(LibraryItem {
media_id,
status,
value: item,
});
}
}
}
serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string())
}
pub(crate) fn watched_video_ids_json(document_json: &str) -> String {
let document: Value = match serde_json::from_str(document_json) {
Ok(document) => document,
Err(_) => return "[]".to_string(),
};
let ids = document
.get("watched")
.and_then(Value::as_object)
.into_iter()
.flatten()
.filter_map(|(video_id, watched)| (watched.as_bool() == Some(true)).then_some(video_id))
.collect::<Vec<_>>();
serde_json::to_string(&ids).unwrap_or_else(|_| "[]".to_string())
}
pub(crate) fn last_watched_entries_json(document_json: &str) -> String {
object_entries(document_json, "lastWatchedEpisodes")
}
pub(crate) fn continue_watching_entries_json(document_json: &str) -> String {
let document: Value = match serde_json::from_str(document_json) {
Ok(document) => document,
Err(_) => return "[]".to_string(),
};
let entries = document
.get("externalContinueWatching")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|item| {
Some(KeyedValue {
key: item.get("id")?.as_str()?,
value: item,
})
})
.collect::<Vec<_>>();
serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string())
}
fn object_entries(document_json: &str, field: &str) -> String {
let document: Value = match serde_json::from_str(document_json) {
Ok(document) => document,
Err(_) => return "[]".to_string(),
};
let entries = document
.get(field)
.and_then(Value::as_object)
.into_iter()
.flatten()
.map(|(key, value)| KeyedValue { key, value })
.collect::<Vec<_>>();
serde_json::to_string(&entries).unwrap_or_else(|_| "[]".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_all_legacy_library_domains() {
let document = r#"{
"progress":{"movie:1":{"position":12}},
"watchlist":[{"id":"movie:1"}],
"completed":[{"id":"movie:2"}],
"dropped":[],
"watched":{"video:1":true,"video:2":false},
"lastWatchedEpisodes":{"series:1":{"id":"episode:2"}},
"externalContinueWatching":[{"id":"movie:3"}]
}"#;
assert!(progress_entries_json(document).contains("movie:1"));
assert!(library_items_json(document).contains("completed"));
assert_eq!(watched_video_ids_json(document), r#"["video:1"]"#);
assert!(last_watched_entries_json(document).contains("series:1"));
assert!(continue_watching_entries_json(document).contains("movie:3"));
}
}