mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-08-05 16:28:59 +00:00
467 lines
15 KiB
Rust
467 lines
15 KiB
Rust
use super::detail;
|
|
use super::helpers::{active_profile_id, normalize_error, should_sync_watched_state};
|
|
use super::home;
|
|
use super::profile;
|
|
use super::state::GenerationKey;
|
|
use super::{EffectResultInput, HeadlessEngine};
|
|
use crate::runtime::{EffectEnvelope, EffectKind};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
|
|
#[serde(rename_all = "camelCase", default)]
|
|
pub(super) struct LibraryState {
|
|
active_profile_id: String,
|
|
is_loading: bool,
|
|
watchlist: Value,
|
|
continue_watching: Value,
|
|
liked: Value,
|
|
watched: Value,
|
|
dropped: Value,
|
|
completed: Value,
|
|
last_command: Value,
|
|
last_write: Value,
|
|
last_write_error: Value,
|
|
pending_playback_progress: Value,
|
|
saved_playback_progress: Value,
|
|
last_watched_sync: Value,
|
|
last_watched_sync_error: Value,
|
|
error: Value,
|
|
generation: u64,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ReadLibraryStatePayload {
|
|
profile_id: String,
|
|
}
|
|
|
|
#[derive(Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ToggleWatchlistCommand {
|
|
#[serde(rename = "type")]
|
|
kind: &'static str,
|
|
item: Value,
|
|
}
|
|
|
|
#[derive(Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ToggleLibraryStatusCommand {
|
|
#[serde(rename = "type")]
|
|
kind: &'static str,
|
|
list: String,
|
|
item: Value,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct WriteLibraryCommandPayload {
|
|
profile_id: String,
|
|
command: Value,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct WriteFeedbackPayload {
|
|
id: String,
|
|
value: Option<bool>,
|
|
meta: Value,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ClearPlaybackProgressPayload {
|
|
profile: Value,
|
|
meta: Value,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct PlaybackProgress {
|
|
meta: Value,
|
|
time_offset: i64,
|
|
duration: i64,
|
|
last_video_id: Option<String>,
|
|
last_stream_index: Option<i32>,
|
|
last_episode_name: Option<String>,
|
|
last_episode_season: Option<i64>,
|
|
last_episode_number: Option<i64>,
|
|
last_episode_thumbnail: Option<String>,
|
|
last_stream_url: Option<String>,
|
|
last_stream_title: Option<String>,
|
|
last_audio_language: Option<String>,
|
|
last_subtitle_language: Option<String>,
|
|
source: &'static str,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct WritePlaybackProgressPayload {
|
|
profile_id: String,
|
|
profile: Value,
|
|
scrobble_trakt_pause: bool,
|
|
progress: PlaybackProgress,
|
|
}
|
|
|
|
#[derive(Clone, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct MarkWatchedCommand {
|
|
#[serde(rename = "type")]
|
|
kind: &'static str,
|
|
series_id: String,
|
|
video_ids: Vec<String>,
|
|
watched: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct SyncWatchedStatePayload {
|
|
profile: Value,
|
|
meta: Value,
|
|
episodes: Vec<Value>,
|
|
watched: bool,
|
|
}
|
|
|
|
pub(super) fn set_active_profile_id(engine: &mut HeadlessEngine, id: &str) {
|
|
engine.state.library.active_profile_id = id.to_string();
|
|
}
|
|
|
|
pub(super) fn dispatch_profile_activated(
|
|
engine: &mut HeadlessEngine,
|
|
profile: crate::types::Profile,
|
|
) -> Vec<EffectEnvelope> {
|
|
profile::activate(engine, profile);
|
|
vec![]
|
|
}
|
|
|
|
pub(super) fn dispatch_hydrate(
|
|
engine: &mut HeadlessEngine,
|
|
profile_id: Option<String>,
|
|
) -> Vec<EffectEnvelope> {
|
|
let generation = engine.bump_generation(GenerationKey::Library);
|
|
let resolved_profile_id =
|
|
profile_id.unwrap_or_else(|| active_profile_id(&engine.state, &Value::Null));
|
|
engine.state.library.active_profile_id = resolved_profile_id.clone();
|
|
engine.state.library.is_loading = true;
|
|
engine.state.library.error = Value::Null;
|
|
engine.state.library.generation = generation;
|
|
vec![engine.effect(
|
|
EffectKind::ReadLibraryState,
|
|
generation,
|
|
ReadLibraryStatePayload {
|
|
profile_id: resolved_profile_id,
|
|
},
|
|
)]
|
|
}
|
|
|
|
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, &profile.unwrap_or(Value::Null));
|
|
let command = ToggleWatchlistCommand {
|
|
kind: "toggleWatchlist",
|
|
item,
|
|
};
|
|
let command_value = serde_json::to_value(&command).unwrap_or(Value::Null);
|
|
engine.state.library.last_command = command_value.clone();
|
|
vec![engine.effect(
|
|
EffectKind::WriteLibraryCommand,
|
|
generation,
|
|
WriteLibraryCommandPayload {
|
|
profile_id,
|
|
command: command_value,
|
|
},
|
|
)]
|
|
}
|
|
|
|
pub(super) fn dispatch_toggle_status(
|
|
engine: &mut HeadlessEngine,
|
|
list: String,
|
|
item: Value,
|
|
) -> Vec<EffectEnvelope> {
|
|
let generation = engine.bump_generation(GenerationKey::Library);
|
|
let profile_id = active_profile_id(&engine.state, &Value::Null);
|
|
let command = ToggleLibraryStatusCommand {
|
|
kind: "toggleLibraryStatus",
|
|
list,
|
|
item,
|
|
};
|
|
let command_value = serde_json::to_value(&command).unwrap_or(Value::Null);
|
|
engine.state.library.last_command = command_value.clone();
|
|
vec![engine.effect(
|
|
EffectKind::WriteLibraryCommand,
|
|
generation,
|
|
WriteLibraryCommandPayload {
|
|
profile_id,
|
|
command: command_value,
|
|
},
|
|
)]
|
|
}
|
|
|
|
pub(super) fn dispatch_set_feedback(
|
|
engine: &mut HeadlessEngine,
|
|
id: String,
|
|
value: Option<bool>,
|
|
meta: Value,
|
|
) -> Vec<EffectEnvelope> {
|
|
let generation = engine.bump_generation(GenerationKey::Library);
|
|
vec![engine.effect(
|
|
EffectKind::WriteFeedback,
|
|
generation,
|
|
WriteFeedbackPayload { id, value, meta },
|
|
)]
|
|
}
|
|
|
|
pub(super) fn dispatch_clear_progress(
|
|
engine: &mut HeadlessEngine,
|
|
profile: Option<Value>,
|
|
meta: Value,
|
|
) -> Vec<EffectEnvelope> {
|
|
let generation = engine.bump_generation(GenerationKey::Library);
|
|
vec![engine.effect(
|
|
EffectKind::ClearPlaybackProgress,
|
|
generation,
|
|
ClearPlaybackProgressPayload {
|
|
profile: profile.unwrap_or(Value::Null),
|
|
meta,
|
|
},
|
|
)]
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(super) fn dispatch_save_progress(
|
|
engine: &mut HeadlessEngine,
|
|
profile: Option<Value>,
|
|
meta: Value,
|
|
time_offset: i64,
|
|
duration: i64,
|
|
last_video_id: Option<String>,
|
|
last_stream_index: Option<i32>,
|
|
last_episode_name: Option<String>,
|
|
last_episode_season: Option<i64>,
|
|
last_episode_number: Option<i64>,
|
|
last_episode_thumbnail: Option<String>,
|
|
last_stream_url: Option<String>,
|
|
last_stream_title: Option<String>,
|
|
last_audio_language: Option<String>,
|
|
last_subtitle_language: Option<String>,
|
|
scrobble_trakt_pause: Option<bool>,
|
|
) -> Vec<EffectEnvelope> {
|
|
let generation = engine.bump_generation(GenerationKey::Library);
|
|
let profile_id = active_profile_id(&engine.state, &Value::Null);
|
|
let progress = PlaybackProgress {
|
|
meta,
|
|
time_offset: time_offset.max(0),
|
|
duration: duration.max(0),
|
|
last_video_id,
|
|
last_stream_index,
|
|
last_episode_name,
|
|
last_episode_season,
|
|
last_episode_number,
|
|
last_episode_thumbnail,
|
|
last_stream_url,
|
|
last_stream_title,
|
|
last_audio_language,
|
|
last_subtitle_language,
|
|
source: "local",
|
|
};
|
|
engine.state.library.pending_playback_progress =
|
|
serde_json::to_value(&progress).unwrap_or(Value::Null);
|
|
vec![engine.effect(
|
|
EffectKind::WritePlaybackProgress,
|
|
generation,
|
|
WritePlaybackProgressPayload {
|
|
profile_id,
|
|
profile: profile.unwrap_or(Value::Null),
|
|
scrobble_trakt_pause: scrobble_trakt_pause.unwrap_or(true),
|
|
progress,
|
|
},
|
|
)]
|
|
}
|
|
|
|
pub(super) fn dispatch_mark_watched(
|
|
engine: &mut HeadlessEngine,
|
|
series_id: String,
|
|
video_ids: Vec<String>,
|
|
watched: Option<bool>,
|
|
meta: Option<Value>,
|
|
episodes: Option<Vec<Value>>,
|
|
profile: Option<Value>,
|
|
) -> Vec<EffectEnvelope> {
|
|
let generation = engine.bump_generation(GenerationKey::Library);
|
|
let profile_id = active_profile_id(&engine.state, &Value::Null);
|
|
let watched_value = watched.unwrap_or(true);
|
|
let clean_video_ids: Vec<String> = video_ids
|
|
.into_iter()
|
|
.filter(|value| !value.trim().is_empty())
|
|
.fold(Vec::new(), |mut acc, value| {
|
|
if !acc.contains(&value) {
|
|
acc.push(value);
|
|
}
|
|
acc
|
|
});
|
|
let command = MarkWatchedCommand {
|
|
kind: "markWatched",
|
|
series_id,
|
|
video_ids: clean_video_ids,
|
|
watched: watched_value,
|
|
};
|
|
let command_value = serde_json::to_value(&command).unwrap_or(Value::Null);
|
|
engine.state.library.last_command = command_value.clone();
|
|
let mut effects = vec![engine.effect(
|
|
EffectKind::WriteLibraryCommand,
|
|
generation,
|
|
WriteLibraryCommandPayload {
|
|
profile_id,
|
|
command: command_value,
|
|
},
|
|
)];
|
|
if should_sync_watched_state(profile.as_ref(), meta.as_ref()) {
|
|
effects.push(engine.effect(
|
|
EffectKind::SyncWatchedState,
|
|
generation,
|
|
SyncWatchedStatePayload {
|
|
profile: profile.unwrap_or(Value::Null),
|
|
meta: meta.unwrap_or(Value::Null),
|
|
episodes: episodes.unwrap_or_default(),
|
|
watched: watched_value,
|
|
},
|
|
));
|
|
}
|
|
effects
|
|
}
|
|
|
|
pub(super) fn complete(
|
|
engine: &mut HeadlessEngine,
|
|
effect_type: &str,
|
|
generation: u64,
|
|
result: &EffectResultInput,
|
|
) -> Vec<EffectEnvelope> {
|
|
match effect_type {
|
|
"readLibraryState" => {
|
|
if generation == engine.state.runtime.get(GenerationKey::Library) {
|
|
engine.state.library.is_loading = false;
|
|
if result.status.is_ok() {
|
|
engine.state.library.watchlist = result
|
|
.value
|
|
.get("watchlist")
|
|
.cloned()
|
|
.unwrap_or_else(|| serde_json::json!([]));
|
|
engine.state.library.continue_watching = result
|
|
.value
|
|
.get("continueWatching")
|
|
.cloned()
|
|
.unwrap_or_else(|| serde_json::json!([]));
|
|
engine.state.library.liked = result
|
|
.value
|
|
.get("liked")
|
|
.cloned()
|
|
.unwrap_or_else(|| serde_json::json!([]));
|
|
engine.state.library.watched = result
|
|
.value
|
|
.get("watched")
|
|
.cloned()
|
|
.unwrap_or_else(|| serde_json::json!({}));
|
|
engine.state.library.dropped = result
|
|
.value
|
|
.get("dropped")
|
|
.cloned()
|
|
.unwrap_or_else(|| serde_json::json!([]));
|
|
engine.state.library.completed = result
|
|
.value
|
|
.get("completed")
|
|
.cloned()
|
|
.unwrap_or_else(|| serde_json::json!([]));
|
|
engine.state.library.error = Value::Null;
|
|
} else {
|
|
engine.state.library.error = normalize_error(result.error.clone());
|
|
}
|
|
}
|
|
}
|
|
"writeLibraryCommand" => {
|
|
if generation == engine.state.runtime.get(GenerationKey::Library) {
|
|
if result.status.is_ok() {
|
|
engine.state.library.last_write = result.value.clone();
|
|
engine.state.library.last_write_error = Value::Null;
|
|
if let Some(value) = engine
|
|
.state
|
|
.library
|
|
.last_write
|
|
.get("isInWatchlist")
|
|
.cloned()
|
|
{
|
|
detail::set_is_in_watchlist(engine, value);
|
|
}
|
|
if let Some(value) = engine
|
|
.state
|
|
.library
|
|
.last_write
|
|
.get("localWatchedVideoIds")
|
|
.cloned()
|
|
{
|
|
detail::set_local_watched_video_ids(engine, value);
|
|
}
|
|
} else {
|
|
engine.state.library.last_write_error = normalize_error(result.error.clone());
|
|
}
|
|
}
|
|
}
|
|
"writeFeedback" => {
|
|
if generation == engine.state.runtime.get(GenerationKey::Library) {
|
|
if result.status.is_ok() {
|
|
detail::set_feedback(
|
|
engine,
|
|
result.value.get("feedback").cloned().unwrap_or(Value::Null),
|
|
);
|
|
engine.state.library.last_write_error = Value::Null;
|
|
} else {
|
|
engine.state.library.last_write_error = normalize_error(result.error.clone());
|
|
}
|
|
}
|
|
}
|
|
"clearPlaybackProgress" => {
|
|
if generation == engine.state.runtime.get(GenerationKey::Library) {
|
|
if result.status.is_ok() {
|
|
detail::clear_saved_playback(engine);
|
|
engine.state.library.last_write_error = Value::Null;
|
|
// Remove the dropped item from home.continueWatching so stale state
|
|
// doesn't reappear when the user navigates back to the home screen.
|
|
if let Some(dropped_id) = result.value.get("droppedId").and_then(Value::as_str)
|
|
{
|
|
home::remove_from_continue_watching(engine, dropped_id);
|
|
}
|
|
} else {
|
|
engine.state.library.last_write_error = normalize_error(result.error.clone());
|
|
}
|
|
}
|
|
}
|
|
"writePlaybackProgress" => {
|
|
if generation == engine.state.runtime.get(GenerationKey::Library) {
|
|
if result.status.is_ok() {
|
|
engine.state.library.saved_playback_progress =
|
|
engine.state.library.pending_playback_progress.clone();
|
|
engine.state.library.pending_playback_progress = Value::Null;
|
|
engine.state.library.last_write_error = Value::Null;
|
|
} else {
|
|
engine.state.library.last_write_error = normalize_error(result.error.clone());
|
|
}
|
|
}
|
|
}
|
|
"syncWatchedState" => {
|
|
if generation == engine.state.runtime.get(GenerationKey::Library) {
|
|
if result.status.is_ok() {
|
|
engine.state.library.last_watched_sync = result.value.clone();
|
|
engine.state.library.last_watched_sync_error = Value::Null;
|
|
} else {
|
|
engine.state.library.last_watched_sync_error =
|
|
normalize_error(result.error.clone());
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
vec![]
|
|
}
|