Menu
BifrOSt-Apps
publicLatest change f35619cfe0419c596645328bf0e1b682ad3a4473 - Harden RÚV for the 0.1.1 candidate by Ólafur Búi Ólafsson
// SPDX-License-Identifier: MIT
use gst::prelude::*;
use iced_video_player::{Error as VideoError, Video, gst, gst_app};
use std::time::{Duration, Instant};
use url::Url;
const INVALID_MEDIA_URL: &str = "The media address is invalid.";
const UNSUPPORTED_MEDIA_SOURCE: &str = "This media source is not supported.";
const PLAYBACK_START_FAILED: &str =
"The stream could not be opened. Check the connection and media support.";
const PREVIEW_TIMED_OUT: &str = "The live preview timed out before a frame was available.";
const PREVIEW_CAPTURE_BUDGET: Duration = Duration::from_secs(12);
// Live resolver responses use short-lived hosts such as ed4.cdn.ruv.is, while
// fallback live and VOD streams use two fixed Akamai tenants. Accept only the
// RÚV-owned CDN suffix and those exact external tenants.
const MEDIA_HOSTS: &[&str] = &["ruvlive.akamaized.net", "ruv-vod.akamaized.net"];
#[derive(Debug)]
pub struct PlayerState {
pub video: Option<Video>,
pub paused: bool,
pub muted: bool,
pub volume: f64,
pub error: Option<String>,
}
impl Default for PlayerState {
fn default() -> Self {
Self {
video: None,
paused: false,
muted: false,
volume: 1.0,
error: None,
}
}
}
impl PlayerState {
/// Adopts a pipeline that `prepare_video` built off the UI thread
/// (APP-5.1), replacing whatever was playing.
pub fn attach(&mut self, mut video: Video) {
// Stop and discard the old pipeline before replacing it. A new
// stream must never leave an earlier one playing invisibly.
self.clear();
video.set_volume(self.volume);
video.set_muted(self.muted);
video.set_paused(false);
self.video = Some(video);
self.paused = false;
self.error = None;
}
pub fn clear(&mut self) {
// Video's Drop implementation moves its GStreamer pipeline to Null
// and then joins its frame worker; hand it to a background thread so
// the UI executor never blocks on teardown (APP-5.1).
if let Some(video) = self.video.take() {
discard_video(video);
}
self.paused = false;
self.error = None;
}
pub fn toggle_pause(&mut self) {
let Some(video) = self.video.as_mut() else {
self.paused = false;
return;
};
if video.eos() {
if video.restart_stream().is_ok() {
self.paused = false;
}
return;
}
self.paused = !self.paused;
video.set_paused(self.paused);
}
pub fn toggle_mute(&mut self) {
self.muted = !self.muted;
if let Some(video) = self.video.as_mut() {
video.set_muted(self.muted);
}
}
pub fn set_volume(&mut self, volume: f64) {
self.volume = if volume.is_nan() {
0.0
} else {
volume.clamp(0.0, 1.0)
};
if let Some(video) = self.video.as_mut() {
video.set_volume(self.volume);
}
}
pub fn position_seconds(&self) -> f64 {
self.video
.as_ref()
.map_or(0.0, |video| video.position().as_secs_f64())
}
pub fn duration_seconds(&self) -> f64 {
self.video
.as_ref()
.map_or(0.0, |video| video.duration().as_secs_f64())
}
pub fn seek_to(&mut self, seconds: f64) {
let Some(video) = self.video.as_mut() else {
return;
};
let duration = video.duration().as_secs_f64();
if !seconds.is_finite() || duration <= 0.0 {
return;
}
let position = seconds.clamp(0.0, duration);
let _ = video.seek(Duration::from_secs_f64(position), false);
}
pub fn skip_by(&mut self, seconds: f64) {
self.seek_to(self.position_seconds() + seconds);
}
pub fn mark_ended(&mut self) {
self.paused = true;
}
pub fn fail_playback(&mut self) {
self.clear();
self.error = Some(PLAYBACK_START_FAILED.to_owned());
}
pub fn video(&self) -> Option<&Video> {
self.video.as_ref()
}
}
impl Drop for PlayerState {
fn drop(&mut self) {
// Make teardown explicit rather than relying on field drop order.
drop(self.video.take());
}
}
/// Validates the source and builds the GStreamer pipeline. Pipeline
/// construction blocks; run this on a blocking executor thread, never on the
/// UI executor (APP-5.1).
pub fn prepare_video(source: &str) -> Result<Video, String> {
let uri = validate_media_url(source).map_err(str::to_owned)?;
open_video(&uri).map_err(|error| {
eprintln!("RÚV playback initialization failed: {error}");
PLAYBACK_START_FAILED.to_owned()
})
}
/// Drops a video pipeline on a detached background thread so GStreamer
/// teardown and the frame-worker join never stall the UI (APP-5.1).
pub fn discard_video(video: Video) {
std::thread::spawn(move || drop(video));
}
fn open_video(uri: &Url) -> Result<Video, VideoError> {
gst::init()?;
let pipeline = format!(
"playbin3 uri=\"{}\" text-sink=\"appsink name=iced_text sync=true caps=text/x-raw\" video-sink=\"videoscale ! videoconvert ! appsink name=iced_video drop=true caps=video/x-raw,format=NV12,pixel-aspect-ratio=1/1\"",
uri.as_str()
);
let pipeline = gst::parse::launch(&pipeline)?
.downcast::<gst::Pipeline>()
.map_err(|_| VideoError::Cast)?;
let video_sink = appsink_from_property(&pipeline, "video-sink", "iced_video")?;
let text_sink = pipeline
.property::<gst::Element>("text-sink")
.downcast::<gst_app::AppSink>()
.map_err(|_| VideoError::AppSink("iced_text".to_owned()))?;
Video::from_gst_pipeline(pipeline, video_sink, Some(text_sink))
}
#[derive(Clone, Debug)]
pub struct PreviewFrame {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
/// Resets the preview pipeline to `Null` on every exit path, including
/// early returns and panics, so a failed capture never leaks a running
/// GStreamer pipeline.
struct PipelineGuard(gst::Pipeline);
impl Drop for PipelineGuard {
fn drop(&mut self) {
let _ = self.0.set_state(gst::State::Null);
}
}
pub fn capture_live_preview(source: &str) -> Result<PreviewFrame, String> {
let uri = validate_media_url(source).map_err(str::to_owned)?;
gst::init().map_err(|error| error.to_string())?;
let pipeline = format!(
"playbin3 uri=\"{}\" audio-sink=\"fakesink sync=false\" text-sink=\"fakesink sync=false\" video-sink=\"videoscale ! videoconvert ! appsink name=preview caps=video/x-raw,format=RGBA,width=640,height=360,pixel-aspect-ratio=1/1\"",
uri.as_str()
);
let pipeline = gst::parse::launch(&pipeline)
.map_err(|error| error.to_string())?
.downcast::<gst::Pipeline>()
.map_err(|_| "The preview pipeline has an unexpected type.".to_owned())?;
let sink = appsink_from_property(&pipeline, "video-sink", "preview")
.map_err(|error| error.to_string())?;
let guard = PipelineGuard(pipeline);
let pipeline = &guard.0;
let deadline = Instant::now() + PREVIEW_CAPTURE_BUDGET;
pipeline
.set_state(gst::State::Playing)
.map_err(|error| error.to_string())?;
// Poll instead of blocking: `pull_sample()` without a timeout can hang
// forever on a stalled live stream. Every wait slice is clamped to the
// remaining budget so the total capture time is strictly bounded, and
// bus errors are surfaced as soon as they appear.
let sample = loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(PREVIEW_TIMED_OUT.to_owned());
}
let slice = remaining.min(Duration::from_millis(250));
let timeout = gst::ClockTime::from_nseconds(slice.as_nanos() as u64);
if let Some(sample) = sink.try_pull_sample(timeout) {
break sample;
}
if sink.is_eos() {
return Err(pipeline_error(pipeline));
}
if let Some(error) = bus_error(pipeline) {
return Err(error);
}
};
let caps = sample
.caps()
.ok_or_else(|| "The preview frame has no media capabilities.".to_owned())?;
let structure = caps
.structure(0)
.ok_or_else(|| "The preview frame has invalid media capabilities.".to_owned())?;
let width = structure
.get::<i32>("width")
.map_err(|_| "The preview width is unavailable.".to_owned())?;
let height = structure
.get::<i32>("height")
.map_err(|_| "The preview height is unavailable.".to_owned())?;
let width = u32::try_from(width).map_err(|_| "The preview width is invalid.".to_owned())?;
let height = u32::try_from(height).map_err(|_| "The preview height is invalid.".to_owned())?;
let buffer = sample
.buffer()
.ok_or_else(|| "The preview frame has no pixel buffer.".to_owned())?;
let map = buffer
.map_readable()
.map_err(|_| "The preview frame could not be read.".to_owned())?;
let expected = width as usize * height as usize * 4;
if map.as_slice().len() != expected {
return Err("The preview frame has an unexpected pixel layout.".to_owned());
}
Ok(PreviewFrame {
width,
height,
pixels: map.as_slice().to_vec(),
})
}
fn bus_error(pipeline: &gst::Pipeline) -> Option<String> {
let bus = pipeline.bus()?;
for message in bus.iter_timed(gst::ClockTime::ZERO) {
if let gst::MessageView::Error(error) = message.view() {
return Some(format!(
"The live preview pipeline failed: {}",
error.error()
));
}
}
None
}
fn pipeline_error(pipeline: &gst::Pipeline) -> String {
bus_error(pipeline)
.unwrap_or_else(|| "The live preview ended before a frame was available.".to_owned())
}
fn appsink_from_property(
pipeline: &gst::Pipeline,
property: &str,
name: &str,
) -> Result<gst_app::AppSink, VideoError> {
pipeline
.property::<gst::Element>(property)
.pads()
.first()
.and_then(|pad| pad.clone().dynamic_cast::<gst::GhostPad>().ok())
.and_then(|pad| pad.parent_element())
.and_then(|element| element.downcast::<gst::Bin>().ok())
.and_then(|bin| bin.by_name(name))
.and_then(|element| element.downcast::<gst_app::AppSink>().ok())
.ok_or_else(|| VideoError::AppSink(name.to_owned()))
}
fn validate_media_url(source: &str) -> Result<Url, &'static str> {
// url::Url accepts some surrounding ASCII whitespace; rejecting it here
// keeps the accepted address identical to the value retained in `source`.
if source.is_empty() || source.trim() != source {
return Err(INVALID_MEDIA_URL);
}
let uri = Url::parse(source).map_err(|_| INVALID_MEDIA_URL)?;
if uri.scheme() != "https"
|| !uri.username().is_empty()
|| uri.password().is_some()
|| uri.port().is_some_and(|port| port != 443)
|| uri.fragment().is_some()
{
return Err(UNSUPPORTED_MEDIA_SOURCE);
}
let host = uri.host_str().ok_or(INVALID_MEDIA_URL)?;
if !host.ends_with(".cdn.ruv.is") && !MEDIA_HOSTS.contains(&host) {
return Err(UNSUPPORTED_MEDIA_SOURCE);
}
if !uri.path().to_ascii_lowercase().ends_with(".m3u8") {
return Err(UNSUPPORTED_MEDIA_SOURCE);
}
Ok(uri)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_only_https_ruv_hls_sources() {
assert!(
validate_media_url("https://ruvlive.akamaized.net/out/v1/channel/index.m3u8").is_ok()
);
assert!(validate_media_url("https://switcher.cdn.ruv.is/resolve/ruv/index.m3u8").is_ok());
assert!(
validate_media_url("https://ed4.cdn.ruv.is/token/live/d0/ruv/channel/index.m3u8")
.is_ok()
);
for source in [
"http://ruvlive.akamaized.net/out/v1/channel/index.m3u8",
"https://example.com/out/v1/channel/index.m3u8",
"https://user@ruvlive.akamaized.net/out/v1/channel/index.m3u8",
"https://ruvlive.akamaized.net/out/v1/channel/index.mp4",
" https://ruvlive.akamaized.net/out/v1/channel/index.m3u8",
] {
assert!(validate_media_url(source).is_err(), "{source}");
}
}
#[test]
fn media_policy_rejects_deceptive_suffixes_userinfo_ips_ports_and_fragments() {
// Host comparison happens on the parser-normalized (lowercased) host,
// and the explicit default port is equivalent to no port at all.
assert!(
validate_media_url("https://RUVLIVE.AKAMAIZED.NET/out/v1/channel/index.m3u8").is_ok()
);
assert!(validate_media_url("https://ed4.cdn.ruv.is/token/live/INDEX.M3U8").is_ok());
assert!(validate_media_url("https://ed4.cdn.ruv.is:443/token/live/index.m3u8").is_ok());
for source in [
// Deceptive suffixes around the one trusted wildcard.
"https://evil-cdn.ruv.is.attacker.com/live/index.m3u8",
"https://cdn.ruv.is.evil.com/live/index.m3u8",
"https://CDN.RUV.IS.EVIL.COM/live/index.m3u8",
"https://notcdn.ruv.is/live/index.m3u8",
"https://ruvlive.akamaized.net.evil.com/out/index.m3u8",
// Bare apex hosts: only subdomains of .cdn.ruv.is are trusted.
"https://cdn.ruv.is/live/index.m3u8",
"https://ruv.is/live/index.m3u8",
// Userinfo, foreign port, IP literals, fragment, path tricks.
"https://user:leyndarmal@ed4.cdn.ruv.is/live/index.m3u8",
"https://ed4.cdn.ruv.is:8443/live/index.m3u8",
"https://151.101.1.1/live/index.m3u8",
"https://[2606:2e00::1]/live/index.m3u8",
"https://ed4.cdn.ruv.is/live/index.m3u8#fragment",
"https://ed4.cdn.ruv.is/live/playlist.m3u8.mp4",
] {
assert!(validate_media_url(source).is_err(), "{source}");
}
}
#[test]
fn volume_and_mute_state_are_bounded_without_a_loaded_stream() {
let mut player = PlayerState::default();
player.set_volume(2.0);
assert_eq!(player.volume, 1.0);
player.set_volume(-1.0);
assert_eq!(player.volume, 0.0);
player.set_volume(f64::NAN);
assert_eq!(player.volume, 0.0);
assert!(!player.muted);
player.toggle_mute();
assert!(player.muted);
player.toggle_mute();
assert!(!player.muted);
}
#[test]
fn seeking_without_media_is_a_safe_no_op() {
let mut player = PlayerState::default();
player.seek_to(120.0);
player.skip_by(-10.0);
assert_eq!(player.position_seconds(), 0.0);
assert_eq!(player.duration_seconds(), 0.0);
assert!(player.error.is_none());
}
#[test]
fn infinite_volume_is_clamped_and_nonfinite_seeks_are_ignored() {
let mut player = PlayerState::default();
player.set_volume(f64::INFINITY);
assert_eq!(player.volume, 1.0);
player.set_volume(f64::NEG_INFINITY);
assert_eq!(player.volume, 0.0);
player.seek_to(f64::NAN);
player.seek_to(f64::INFINITY);
player.skip_by(f64::NAN);
assert_eq!(player.position_seconds(), 0.0);
assert!(player.error.is_none());
}
}