Refine library history and recommendation requests
Some checks are pending
CI / test (push) Waiting to run
CI / clippy (push) Waiting to run
CI / bench-check (push) Waiting to run
CI / wasm-check (push) Waiting to run
CI / fmt (push) Waiting to run

This commit is contained in:
KhooLy 2026-07-26 03:14:04 +03:00
parent 0179c0871d
commit 669f36f259
5 changed files with 93 additions and 19 deletions

View file

@ -498,12 +498,14 @@ fn stream_dvcc_strip(upstream: &mut reqwest::blocking::Response, downstream: &mu
/// Returns the number of four-character codes that were replaced.
/// Parse the start offset from an HTTP `Content-Range: bytes START-END/TOTAL` header.
// dvcC box parser
#[cfg(test)]
#[derive(Debug, Clone, Copy)]
struct DvContainerInfo {
profile: u8,
compat_id: u8,
}
#[cfg(test)]
impl DvContainerInfo {
/// Mirrors Kodi's `notHasHDR10fallback` check
/// (DVDVideoCodecAndroidMediaCodec.cpp:543-544 and 698-700):
@ -520,6 +522,7 @@ impl DvContainerInfo {
}
/// Scan `data` for a `dvcC` ISO-BMFF box and return the parsed DV profile info.
#[cfg(test)]
fn scan_dvcc_info(data: &[u8]) -> Option<DvContainerInfo> {
for i in 0..data.len().saturating_sub(8) {
if data[i..i + 4] == *b"dvcC" {
@ -540,6 +543,7 @@ fn scan_dvcc_info(data: &[u8]) -> Option<DvContainerInfo> {
/// byte[3][7:3] dv_level low 5 bits
/// byte[3][2:0] rpu/el/bl_present_flags
/// byte[4][7:4] dv_bl_signal_compatibility_id (4 bits)
#[cfg(test)]
fn parse_dvcc_payload(data: &[u8]) -> Option<DvContainerInfo> {
if data.len() < 5 {
return None;

View file

@ -38,6 +38,7 @@ pub(super) enum AppAction {
id: String,
language: Option<String>,
profile: Option<Value>,
similar_titles_source: Option<String>,
},
#[serde(rename = "detailPrefetchRequested")]
DetailPrefetchRequested {

View file

@ -128,6 +128,7 @@ struct FetchDetailSecondaryPayload {
id: String,
language: String,
profile: Value,
similar_titles_source: Option<String>,
}
#[derive(Serialize)]
@ -260,6 +261,7 @@ pub(super) fn dispatch_secondary(
id: String,
language: Option<String>,
profile: Option<Value>,
similar_titles_source: Option<String>,
) -> Vec<EffectEnvelope> {
let generation = engine
.state
@ -274,6 +276,7 @@ pub(super) fn dispatch_secondary(
id,
language: language.unwrap_or_else(|| "en".to_string()),
profile: profile.unwrap_or(Value::Null),
similar_titles_source,
},
)]
}

View file

@ -176,7 +176,8 @@ impl HeadlessEngine {
id,
language,
profile,
} => detail::dispatch_secondary(self, content_type, id, language, profile),
similar_titles_source,
} => detail::dispatch_secondary(self, content_type, id, language, profile, similar_titles_source),
AppAction::DetailPrefetchRequested {
content_type,
id,

View file

@ -306,9 +306,18 @@ pub(crate) fn library_view_plan_json(args_json: &str) -> Option<String> {
.partial_cmp(&rating(a))
.unwrap_or(std::cmp::Ordering::Equal)
});
let watching_ids: std::collections::HashSet<&str> = watching
.iter()
.filter_map(|item| item.get("id").and_then(Value::as_str))
.collect();
let mut history = all;
history.retain(|item| activity_time(item) > 0);
history.sort_by_key(|item| std::cmp::Reverse(activity_time(item)));
history.retain(|item| {
item.get("id")
.and_then(Value::as_str)
.is_none_or(|id| !watching_ids.contains(id))
&& playback_time(item) > 0
});
history.sort_by_key(|item| std::cmp::Reverse(playback_time(item)));
let tab = args.get("tab").and_then(Value::as_str).unwrap_or("");
let mut items = match tab {
"watchlist" => watchlist.clone(),
@ -495,9 +504,23 @@ fn rating(item: &Value) -> f64 {
}
fn timestamp(item: &Value, key: &str) -> i64 {
item.get(key)
.and_then(Value::as_str)
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
.map(|value| value.timestamp_millis())
.and_then(|value| {
value
.as_i64()
.map(|value| {
if value < 10_000_000_000 {
value * 1_000
} else {
value
}
})
.or_else(|| {
value
.as_str()
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())
.map(|value| value.timestamp_millis())
})
})
.unwrap_or(0)
}
fn status_changed_at(item: &Value) -> &str {
@ -505,19 +528,61 @@ fn status_changed_at(item: &Value) -> &str {
.and_then(Value::as_str)
.unwrap_or("")
}
fn activity_time(item: &Value) -> i64 {
[
"savedAt",
"lastWatchedAt",
"statusChangedAt",
"newEpisodeReleasedAt",
"lastAirDateCheckedAt",
"updatedAt",
]
.iter()
.map(|key| timestamp(item, key))
.find(|value| *value > 0)
.unwrap_or(0)
fn playback_time(item: &Value) -> i64 {
let last_watched = timestamp(item, "lastWatchedAt");
if last_watched > 0 {
return last_watched;
}
let has_playback_state = item
.get("timeOffset")
.and_then(Value::as_i64)
.is_some_and(|value| value > 0)
|| item
.get("lastVideoId")
.and_then(Value::as_str)
.is_some_and(|value| !value.is_empty());
if has_playback_state {
timestamp(item, "savedAt")
} else {
0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn history_excludes_active_watching_and_non_playback_changes() {
let plan = library_view_plan_json(
&json!({
"watchlist": [
{"id": "saved", "savedAt": "2026-07-01T00:00:00Z"},
{"id": "played", "lastVideoId": "played:1:1", "savedAt": "2026-07-02T00:00:00Z"}
],
"watching": [
{"id": "active", "lastVideoId": "active:1:1", "savedAt": "2026-07-03T00:00:00Z"}
],
"completed": [],
"dropped": [],
"progress": {},
"tab": "history"
})
.to_string(),
)
.unwrap();
let items = serde_json::from_str::<Value>(&plan).unwrap()["items"]
.as_array()
.unwrap()
.clone();
assert_eq!(
items,
vec![
json!({"id": "played", "lastVideoId": "played:1:1", "savedAt": "2026-07-02T00:00:00Z"})
]
);
}
}
fn air_time(item: &Value) -> i64 {
let value = timestamp(item, "nextEpisodeAirDate").max(timestamp(item, "newEpisodeReleasedAt"));