Skip to content

Commit a870a43

Browse files
committed
Keep live channel details current
1 parent 34f05c2 commit a870a43

4 files changed

Lines changed: 294 additions & 2 deletions

File tree

src/models/meta_details.rs

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use std::{borrow::Cow, marker::PhantomData};
22

3+
use chrono::{DateTime, Duration, Utc};
34
use enclose::enclose;
45
use futures::FutureExt;
56
use serde::{Deserialize, Serialize};
@@ -13,8 +14,8 @@ use crate::{
1314
},
1415
models::{
1516
common::{
16-
eq_update, resources_update, resources_update_with_vector_content, Loadable,
17-
ResourceLoadable, ResourcesAction,
17+
eq_update, resource_update, resources_update, resources_update_with_vector_content,
18+
Loadable, ResourceAction, ResourceLoadable, ResourcesAction,
1819
},
1920
ctx::{Ctx, CtxError},
2021
},
@@ -63,12 +64,20 @@ pub struct MetaDetails {
6364
pub rating_info: Option<Loadable<RatingInfo, EnvError>>,
6465
#[serde(skip_serializing)]
6566
pub watched: Option<WatchedBitField>,
67+
/// A background refresh keeps the displayed channel and its streams available.
68+
#[serde(skip)]
69+
pub live_schedule_refresh: Option<(ResourceLoadable<MetaItem>, DateTime<Utc>)>,
6670
}
6771

6872
impl<E: Env + 'static> UpdateWithCtx<E> for MetaDetails {
6973
fn update(&mut self, msg: &Msg, ctx: &Ctx) -> Effects {
7074
match msg {
7175
Msg::Action(Action::Load(ActionLoad::MetaDetails(selected))) => {
76+
if self.selected.as_ref().map(|selected| &selected.meta_path)
77+
!= Some(&selected.meta_path)
78+
{
79+
self.live_schedule_refresh = None;
80+
}
7281
let selected_effects = eq_update(&mut self.selected, Some(selected.to_owned()));
7382
let meta_items_effects =
7483
meta_items_update::<E>(&mut self.meta_items, &self.selected, &ctx.profile);
@@ -111,6 +120,7 @@ impl<E: Env + 'static> UpdateWithCtx<E> for MetaDetails {
111120
.join(watched_effects)
112121
}
113122
Msg::Action(Action::Unload) => {
123+
self.live_schedule_refresh = None;
114124
let selected_effects = eq_update(&mut self.selected, None);
115125
let meta_items_effects = eq_update(&mut self.meta_items, vec![]);
116126
let meta_streams_effects = eq_update(&mut self.meta_streams, vec![]);
@@ -129,6 +139,9 @@ impl<E: Env + 'static> UpdateWithCtx<E> for MetaDetails {
129139
.join(watched_effects)
130140
.join(rating_info_effects)
131141
}
142+
Msg::Action(Action::MetaDetails(ActionMetaDetails::RefreshLive)) => {
143+
live_schedule_refresh_update::<E>(self)
144+
}
132145
Msg::Action(Action::MetaDetails(ActionMetaDetails::MarkAsWatched(is_watched))) => {
133146
match &self.library_item {
134147
Some(library_item) => {
@@ -225,6 +238,29 @@ impl<E: Env + 'static> UpdateWithCtx<E> for MetaDetails {
225238
Msg::Internal(Internal::ResourceRequestResult(request, result))
226239
if request.path.resource == META_RESOURCE_NAME =>
227240
{
241+
let live_schedule_effects = self
242+
.live_schedule_refresh
243+
.as_mut()
244+
.map(|(refresh, _)| {
245+
let effects = resource_update::<E, MetaItem>(
246+
refresh,
247+
ResourceAction::ResourceRequestResult { request, result },
248+
);
249+
if effects.has_changed
250+
&& matches!(refresh.content, Some(Loadable::Ready(_)))
251+
{
252+
self.meta_items
253+
.iter_mut()
254+
.find(|resource| resource.request == *request)
255+
.map(|resource| {
256+
eq_update(&mut resource.content, refresh.content.clone())
257+
})
258+
.unwrap_or_else(|| Effects::none().unchanged())
259+
} else {
260+
effects.unchanged()
261+
}
262+
})
263+
.unwrap_or_else(|| Effects::none().unchanged());
228264
let meta_items_effects = resources_update::<E, _>(
229265
&mut self.meta_items,
230266
ResourcesAction::ResourceRequestResult { request, result },
@@ -255,6 +291,7 @@ impl<E: Env + 'static> UpdateWithCtx<E> for MetaDetails {
255291
let watched_effects =
256292
watched_update(&mut self.watched, &self.meta_items, &self.library_item);
257293
selected_override_effects
294+
.join(live_schedule_effects)
258295
.join(meta_items_effects)
259296
.join(meta_streams_effects)
260297
.join(streams_effects)
@@ -543,6 +580,14 @@ fn selected_guess_stream_update(
543580
) {
544581
(_, Some(default_video_id)) => default_video_id.to_owned(),
545582
(0, None) => meta_item.preview.id.to_owned(),
583+
(_, None)
584+
if meta_item
585+
.preview
586+
.behavior_hints
587+
.is_live(&meta_item.preview.r#type) =>
588+
{
589+
meta_item.preview.id.to_owned()
590+
}
546591
_ => return Effects::default(),
547592
};
548593

@@ -563,6 +608,57 @@ fn selected_guess_stream_update(
563608
)
564609
}
565610

611+
fn live_schedule_refresh_update<E: Env + 'static>(details: &mut MetaDetails) -> Effects {
612+
let Some((resource, meta)) = details.meta_items.iter().find_map(|resource| {
613+
resource
614+
.content
615+
.as_ref()
616+
.and_then(Loadable::ready)
617+
.map(|meta| (resource, meta))
618+
}) else {
619+
return Effects::none().unchanged();
620+
};
621+
if !meta.preview.behavior_hints.is_live(&meta.preview.r#type) {
622+
details.live_schedule_refresh = None;
623+
return Effects::none().unchanged();
624+
}
625+
let now = E::now();
626+
let (refresh, requested_at) = details
627+
.live_schedule_refresh
628+
.get_or_insert_with(|| (resource.clone(), now));
629+
if refresh.request != resource.request {
630+
*refresh = resource.clone();
631+
*requested_at = now;
632+
}
633+
if matches!(refresh.content, Some(Loadable::Loading)) {
634+
return Effects::none().unchanged();
635+
}
636+
let coverage_expired = meta
637+
.videos
638+
.iter()
639+
.filter_map(|video| video.epg_info.as_ref())
640+
.filter(|info| info.start_time < info.end_time)
641+
.map(|info| info.end_time)
642+
.max()
643+
.is_some_and(|end| end <= now);
644+
let refresh_after = if coverage_expired || matches!(refresh.content, Some(Loadable::Err(_))) {
645+
Duration::minutes(1)
646+
} else {
647+
Duration::minutes(15)
648+
};
649+
if now - *requested_at < refresh_after {
650+
return Effects::none().unchanged();
651+
}
652+
*requested_at = now;
653+
refresh.content = None;
654+
let request = refresh.request.clone();
655+
resource_update::<E, MetaItem>(
656+
refresh,
657+
ResourceAction::ResourceRequested { request: &request },
658+
)
659+
.unchanged()
660+
}
661+
566662
fn meta_items_update<E: Env + 'static>(
567663
meta_items: &mut Vec<ResourceLoadable<MetaItem>>,
568664
selected: &Option<Selected>,

src/runtime/msg/action.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,8 @@ pub enum ActionLibraryWithFilters {
145145
#[derive(Clone, Deserialize, Debug)]
146146
#[serde(tag = "action", content = "args")]
147147
pub enum ActionMetaDetails {
148+
/// Reports activity on a live channel page so Core can refresh expired schedules.
149+
RefreshLive,
148150
/// Marks the [`LibraryItem`] as watched.
149151
///
150152
/// Applicable when you have single-video (e.g. a movie) and multi-video (e.g. a movie series) item.
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
use chrono::{Duration, TimeZone, Utc};
2+
use futures::future;
3+
use serde_json::json;
4+
5+
use crate::{
6+
models::{
7+
common::{Loadable, ResourceLoadable},
8+
ctx::Ctx,
9+
meta_details::{MetaDetails, Selected},
10+
},
11+
runtime::{
12+
msg::{Action, ActionLoad, ActionMetaDetails, Internal, Msg},
13+
EnvError, EnvFutureExt, UpdateWithCtx,
14+
},
15+
types::{
16+
addon::{Descriptor, ResourcePath, ResourceRequest, ResourceResponse},
17+
profile::Profile,
18+
resource::MetaItem,
19+
},
20+
unit_tests::{TestEnv, FETCH_HANDLER, NOW},
21+
};
22+
23+
fn keep_requests_pending() {
24+
*FETCH_HANDLER.write().unwrap() = Box::new(|request| {
25+
assert!(request.url.starts_with("https://addon/"));
26+
future::pending().boxed_env()
27+
});
28+
}
29+
30+
fn fixture(r#type: &str, explicit_live: bool) -> (MetaDetails, Ctx, ResourceRequest) {
31+
let addon: Descriptor = serde_json::from_value(json!({
32+
"transportUrl": "https://addon/manifest.json",
33+
"manifest": {
34+
"id": "live", "name": "Live", "version": "1.0.0",
35+
"types": [r#type], "resources": ["meta", "stream"], "catalogs": []
36+
}
37+
}))
38+
.unwrap();
39+
let request = ResourceRequest {
40+
base: addon.transport_url.clone(),
41+
path: ResourcePath {
42+
resource: "meta".into(),
43+
r#type: r#type.into(),
44+
id: "channel".into(),
45+
extra: vec![],
46+
},
47+
};
48+
let meta: MetaItem = serde_json::from_value(json!({
49+
"id": "channel", "type": r#type, "name": "Channel",
50+
"behaviorHints": { "isLive": explicit_live },
51+
"videos": [{
52+
"id": "programme-a", "title": "Programme A",
53+
"startTime": "2026-09-06T12:00:00Z", "endTime": "2026-09-06T13:00:00Z"
54+
}]
55+
}))
56+
.unwrap();
57+
let details = MetaDetails {
58+
meta_items: vec![ResourceLoadable {
59+
request: request.clone(),
60+
content: Some(Loadable::Ready(meta)),
61+
}],
62+
..Default::default()
63+
};
64+
let ctx = Ctx {
65+
profile: Profile {
66+
addons: vec![addon],
67+
..Default::default()
68+
},
69+
..Default::default()
70+
};
71+
(details, ctx, request)
72+
}
73+
74+
#[test]
75+
fn live_channel_details_select_the_channel_stream_with_a_schedule() {
76+
let _guard = TestEnv::reset().unwrap();
77+
keep_requests_pending();
78+
for (r#type, explicit_live) in [("tv", false), ("channel", true)] {
79+
let (mut details, ctx, request) = fixture(r#type, explicit_live);
80+
<MetaDetails as UpdateWithCtx<TestEnv>>::update(
81+
&mut details,
82+
&Msg::Action(Action::Load(ActionLoad::MetaDetails(Selected {
83+
meta_path: request.path,
84+
stream_path: None,
85+
guess_stream: true,
86+
}))),
87+
&ctx,
88+
);
89+
assert_eq!(
90+
details
91+
.selected
92+
.as_ref()
93+
.unwrap()
94+
.stream_path
95+
.as_ref()
96+
.unwrap()
97+
.id,
98+
"channel"
99+
);
100+
assert_eq!(details.streams.len(), 1);
101+
assert_eq!(details.streams[0].request.path.id, "channel");
102+
}
103+
}
104+
105+
#[test]
106+
fn live_channel_details_refresh_in_background_and_retry_without_resetting_streams() {
107+
let _guard = TestEnv::reset().unwrap();
108+
keep_requests_pending();
109+
*NOW.write().unwrap() = Utc.with_ymd_and_hms(2026, 9, 6, 12, 30, 0).unwrap();
110+
let (mut details, ctx, request) = fixture("tv", false);
111+
let update = |details: &mut MetaDetails, msg: Msg| {
112+
<MetaDetails as UpdateWithCtx<TestEnv>>::update(details, &msg, &ctx)
113+
};
114+
update(
115+
&mut details,
116+
Msg::Action(Action::Load(ActionLoad::MetaDetails(Selected {
117+
meta_path: request.path.clone(),
118+
stream_path: None,
119+
guess_stream: true,
120+
}))),
121+
);
122+
let stream_request = details.streams[0].request.clone();
123+
update(
124+
&mut details,
125+
Msg::Internal(Internal::ResourceRequestResult(
126+
stream_request,
127+
Box::new(Ok(serde_json::from_value(
128+
json!({"streams": [{"url": "https://channel/live.m3u8"}]}),
129+
)
130+
.unwrap())),
131+
)),
132+
);
133+
let displayed = details.meta_items.clone();
134+
let streams = details.streams.clone();
135+
let selected = details.selected.clone();
136+
let refresh = || Msg::Action(Action::MetaDetails(ActionMetaDetails::RefreshLive));
137+
assert_eq!(update(&mut details, refresh()).into_iter().count(), 0);
138+
*NOW.write().unwrap() += Duration::minutes(14);
139+
assert_eq!(update(&mut details, refresh()).into_iter().count(), 0);
140+
*NOW.write().unwrap() += Duration::minutes(1);
141+
let effects = update(&mut details, refresh());
142+
assert!(!effects.has_changed);
143+
assert_eq!(effects.into_iter().count(), 1);
144+
assert_eq!(details.meta_items, displayed);
145+
assert_eq!(update(&mut details, refresh()).into_iter().count(), 0);
146+
147+
update(
148+
&mut details,
149+
Msg::Internal(Internal::ResourceRequestResult(
150+
request.clone(),
151+
Box::new(Err(EnvError::Fetch("unavailable".into()))),
152+
)),
153+
);
154+
assert_eq!(details.meta_items, displayed);
155+
*NOW.write().unwrap() += Duration::seconds(30);
156+
assert_eq!(update(&mut details, refresh()).into_iter().count(), 0);
157+
*NOW.write().unwrap() += Duration::seconds(30);
158+
assert_eq!(update(&mut details, refresh()).into_iter().count(), 1);
159+
let mut updated = displayed[0]
160+
.content
161+
.as_ref()
162+
.unwrap()
163+
.ready()
164+
.unwrap()
165+
.clone();
166+
updated.videos[0].title = "Updated programme".into();
167+
updated.videos[0].epg_info.as_mut().unwrap().end_time =
168+
Utc.with_ymd_and_hms(2026, 9, 6, 12, 47, 0).unwrap();
169+
update(
170+
&mut details,
171+
Msg::Internal(Internal::ResourceRequestResult(
172+
request.clone(),
173+
Box::new(Ok(ResourceResponse::Meta {
174+
meta: updated.clone(),
175+
})),
176+
)),
177+
);
178+
assert_eq!(
179+
details.meta_items[0].content,
180+
Some(Loadable::Ready(updated))
181+
);
182+
assert_eq!(details.selected, selected);
183+
assert_eq!(details.streams, streams);
184+
185+
*NOW.write().unwrap() = Utc.with_ymd_and_hms(2026, 9, 6, 12, 48, 0).unwrap();
186+
assert_eq!(update(&mut details, refresh()).into_iter().count(), 1);
187+
update(&mut details, Msg::Action(Action::Unload));
188+
assert!(details.live_schedule_refresh.is_none());
189+
190+
let (mut movie, _, _) = fixture("movie", false);
191+
assert_eq!(update(&mut movie, refresh()).into_iter().count(), 0);
192+
assert!(movie.live_schedule_refresh.is_none());
193+
}

src/unit_tests/meta_details/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
mod live_tv;
12
mod mark_video_as_watched;
23
mod override_selected;

0 commit comments

Comments
 (0)