use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;
use super::common::last_path_segment_without_query;
use crate::stream::provider::{EpisodeInfo, SeriesInfo, StreamInfo, StreamProvider};
const YLE_APP_ID: &str = "player_static_prod";
const YLE_APP_KEY: &str = "8930d72170e48303cf5f3867780d549b";
const YLE_API_BASE: &str = "https://player.api.yle.fi/v1/preview";
pub struct YleProvider {
client: Client,
}
impl YleProvider {
pub fn new() -> Result<Self> {
let client = Client::builder().user_agent("nab/1.0").build()?;
Ok(Self { client })
}
fn preview_url(program_id: &str) -> String {
format!(
"{YLE_API_BASE}/{program_id}.json?language=fin&ssl=true&countryCode=FI&host=areenaylefi&app_id={YLE_APP_ID}&app_key={YLE_APP_KEY}&isPortabilityRegion=true"
)
}
fn extract_program_id(url_or_id: &str) -> String {
if url_or_id.starts_with("http") {
last_path_segment_without_query(url_or_id)
.unwrap_or(url_or_id)
.to_string()
} else {
url_or_id.to_string()
}
}
fn select_ongoing(data: YlePreviewData) -> Result<(YleOngoing, bool)> {
if let Some(ongoing) = data.ongoing_ondemand {
Ok((ongoing, false))
} else if let Some(ongoing) = data.ongoing_channel {
Ok((ongoing, true))
} else if let Some(ongoing) = data.ongoing_event {
Ok((ongoing, true))
} else {
Err(anyhow!(
"No active stream found (may be expired or pending)"
))
}
}
async fn fetch_preview(&self, program_id: &str) -> Result<YlePreviewResponse> {
let url = Self::preview_url(program_id);
let resp = self
.client
.get(&url)
.header("Referer", "https://areena.yle.fi")
.header("Origin", "https://areena.yle.fi")
.send()
.await
.with_context(|| format!("Yle preview API request failed for {program_id}"))?;
if !resp.status().is_success() {
return Err(anyhow!(
"Yle API error: {} for program {}",
resp.status(),
program_id
));
}
resp.json()
.await
.context("Failed to parse Yle preview API response")
}
fn parse_episodes_from_next_data(data: &serde_json::Value) -> Vec<EpisodeInfo> {
let mut episodes = Vec::new();
let possible_paths = [
"/props/pageProps/view/tabs/0/content/0/cards",
"/props/pageProps/view/content/episodes",
"/props/pageProps/initialData/episodes",
];
for path in possible_paths {
if let Some(items) = data.pointer(path).and_then(|v| v.as_array()) {
for item in items {
if let Some(ep) = Self::parse_episode_item(item) {
episodes.push(ep);
}
}
if !episodes.is_empty() {
break;
}
}
}
episodes
}
fn parse_episode_item(item: &serde_json::Value) -> Option<EpisodeInfo> {
let id = item
.pointer("/id")
.or_else(|| item.pointer("/uri"))
.and_then(|v| v.as_str())?
.to_string();
let title = item
.pointer("/title/fin")
.or_else(|| item.pointer("/title"))
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
#[allow(clippy::cast_possible_truncation)]
let episode_number = item
.pointer("/episodeNumber")
.and_then(serde_json::Value::as_u64)
.map(|n| n as u32);
#[allow(clippy::cast_possible_truncation)]
let season_number = item
.pointer("/seasonNumber")
.and_then(serde_json::Value::as_u64)
.map(|n| n as u32);
let duration = item
.pointer("/duration/duration_in_seconds")
.or_else(|| item.pointer("/duration"))
.and_then(serde_json::Value::as_u64);
Some(EpisodeInfo {
id,
title,
episode_number,
season_number,
duration_seconds: duration,
publish_date: None,
})
}
fn parse_episodes_from_html(html: &str) -> Vec<EpisodeInfo> {
let mut episodes = Vec::new();
let mut pos = 0;
while let Some(href_start) = html[pos..].find("href=\"/1-") {
let abs_start = pos + href_start + 6; if let Some(href_end) = html[abs_start..].find('"') {
let href = &html[abs_start..abs_start + href_end];
let id = href.trim_start_matches('/').to_string();
if !episodes.iter().any(|e: &EpisodeInfo| e.id == id) {
episodes.push(EpisodeInfo {
id,
title: "Episode".to_string(),
episode_number: None,
season_number: None,
duration_seconds: None,
publish_date: None,
});
}
}
pos = abs_start + 1;
}
episodes
}
}
impl Default for YleProvider {
fn default() -> Self {
Self::new().expect("Failed to create YleProvider")
}
}
impl YleProvider {
pub async fn get_fresh_manifest_url(&self, program_id: &str) -> Result<String> {
use tokio::process::Command;
let id = Self::extract_program_id(program_id);
let url = format!("https://areena.yle.fi/{id}");
let output = Command::new("yle-dl")
.arg("--showurl")
.arg(&url)
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("yle-dl failed: {stderr}"));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let urls: Vec<&str> = stdout.lines().collect();
urls.last()
.map(std::string::ToString::to_string)
.ok_or_else(|| anyhow!("No manifest URL returned by yle-dl"))
}
pub async fn yle_dl_available() -> bool {
use tokio::process::Command;
Command::new("yle-dl")
.arg("--version")
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false)
}
}
#[async_trait]
impl StreamProvider for YleProvider {
fn name(&self) -> &'static str {
"yle"
}
fn matches(&self, url: &str) -> bool {
url.contains("areena.yle.fi") || url.contains("arenan.yle.fi")
}
async fn get_stream_info(&self, id: &str) -> Result<StreamInfo> {
let program_id = Self::extract_program_id(id);
let preview = self.fetch_preview(&program_id).await?;
let (ongoing, is_live) = Self::select_ongoing(preview.data)?;
let manifest_url = ongoing
.manifest_url
.ok_or_else(|| anyhow!("No manifest URL in response"))?;
let title = ongoing
.title
.and_then(|t| t.fin.or(t.swe).or(t.eng))
.unwrap_or_else(|| program_id.clone());
let description = ongoing.description.and_then(|d| d.fin.or(d.swe));
let duration = ongoing.duration.map(|d| d.duration_in_seconds);
let thumbnail_url = ongoing.image.map(|img| {
format!(
"https://images.cdn.yle.fi/image/upload/f_auto,c_limit,w_1080,q_auto/v{}/{}",
img.version.unwrap_or(1),
img.id
)
});
Ok(StreamInfo {
id: program_id,
title,
description,
duration_seconds: duration,
manifest_url,
is_live,
qualities: vec![], thumbnail_url,
})
}
async fn list_series(&self, series_id: &str) -> Result<SeriesInfo> {
let url = format!(
"https://areena.yle.fi/{}",
Self::extract_program_id(series_id)
);
let resp = self.client.get(&url).send().await?;
let html = resp.text().await?;
let next_data_start = html
.find("__NEXT_DATA__")
.and_then(|base| html[base..].find('{').map(|offset| base + offset));
let next_data_end = next_data_start.and_then(|start| {
let mut depth = 0;
for (i, c) in html[start..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return Some(start + i + 1);
}
}
_ => {}
}
}
None
});
if let (Some(start), Some(end)) = (next_data_start, next_data_end) {
let json_str = &html[start..end];
if let Ok(next_data) = serde_json::from_str::<serde_json::Value>(json_str) {
let title = next_data
.pointer("/props/pageProps/meta/title")
.and_then(|v| v.as_str())
.unwrap_or("Unknown Series")
.to_string();
let episodes = Self::parse_episodes_from_next_data(&next_data);
return Ok(SeriesInfo {
id: series_id.to_string(),
title,
episodes,
});
}
}
let episodes = Self::parse_episodes_from_html(&html);
Ok(SeriesInfo {
id: series_id.to_string(),
title: "Unknown Series".to_string(),
episodes,
})
}
}
#[derive(Debug, Deserialize)]
struct YlePreviewResponse {
data: YlePreviewData,
}
#[derive(Debug, Deserialize)]
struct YlePreviewData {
ongoing_ondemand: Option<YleOngoing>,
ongoing_channel: Option<YleOngoing>,
ongoing_event: Option<YleOngoing>,
#[allow(dead_code)]
pending_event: Option<YleOngoing>,
#[allow(dead_code)]
gone: Option<YleGone>,
}
#[derive(Debug, Deserialize)]
struct YleOngoing {
#[allow(dead_code)]
media_id: Option<String>,
manifest_url: Option<String>,
title: Option<LocalizedText>,
description: Option<LocalizedText>,
duration: Option<YleDuration>,
#[allow(dead_code)]
start_time: Option<String>,
image: Option<YleImage>,
#[allow(dead_code)]
content_type: Option<String>,
#[allow(dead_code)]
region: Option<String>,
}
#[derive(Debug, Deserialize)]
struct YleGone {
#[allow(dead_code)]
title: Option<LocalizedText>,
#[allow(dead_code)]
description: Option<LocalizedText>,
}
#[derive(Debug, Deserialize)]
struct LocalizedText {
fin: Option<String>,
swe: Option<String>,
eng: Option<String>,
}
#[derive(Debug, Deserialize)]
struct YleDuration {
duration_in_seconds: u64,
}
#[derive(Debug, Deserialize)]
struct YleImage {
id: String,
version: Option<u64>,
}
#[cfg(test)]
mod tests {
use super::*;
fn test_ongoing(manifest_url: &str) -> YleOngoing {
YleOngoing {
media_id: None,
manifest_url: Some(manifest_url.to_string()),
title: None,
description: None,
duration: None,
start_time: None,
image: None,
content_type: None,
region: None,
}
}
#[test]
fn test_extract_program_id() {
assert_eq!(YleProvider::extract_program_id("1-50552121"), "1-50552121");
assert_eq!(
YleProvider::extract_program_id("https://areena.yle.fi/1-50552121"),
"1-50552121"
);
assert_eq!(
YleProvider::extract_program_id("https://areena.yle.fi/1-50552121?foo=bar"),
"1-50552121"
);
}
#[test]
fn test_preview_url() {
let url = YleProvider::preview_url("1-50552121");
assert!(url.contains("player.api.yle.fi"));
assert!(url.contains("app_key="));
assert!(url.contains("1-50552121"));
}
#[test]
fn test_matches() {
let provider = YleProvider::default();
assert!(provider.matches("https://areena.yle.fi/1-50552121"));
assert!(provider.matches("https://arenan.yle.fi/1-50552121"));
assert!(!provider.matches("https://example.com"));
}
#[test]
fn test_select_ongoing_prefers_ondemand_and_marks_not_live() {
let (ongoing, is_live) = YleProvider::select_ongoing(YlePreviewData {
ongoing_ondemand: Some(test_ongoing("https://vod.example/manifest.m3u8")),
ongoing_channel: Some(test_ongoing("https://live.example/channel.m3u8")),
ongoing_event: Some(test_ongoing("https://live.example/event.m3u8")),
pending_event: None,
gone: None,
})
.expect("ondemand variant should be selected");
assert_eq!(
ongoing.manifest_url.as_deref(),
Some("https://vod.example/manifest.m3u8")
);
assert!(!is_live);
}
#[test]
fn test_select_ongoing_marks_live_when_channel_selected() {
let (ongoing, is_live) = YleProvider::select_ongoing(YlePreviewData {
ongoing_ondemand: None,
ongoing_channel: Some(test_ongoing("https://live.example/channel.m3u8")),
ongoing_event: None,
pending_event: None,
gone: None,
})
.expect("channel variant should be selected");
assert_eq!(
ongoing.manifest_url.as_deref(),
Some("https://live.example/channel.m3u8")
);
assert!(is_live);
}
}