use anyhow::{Context, Result, anyhow};
use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;
use super::common::{last_path_segment_without_query, segment_after, strip_query};
use crate::stream::provider::{EpisodeInfo, SeriesInfo, StreamInfo, StreamProvider};
const NRK_PSAPI_BASE: &str = "https://psapi.nrk.no";
const NRK_PLAYBACK_BASE: &str = "https://psapi.nrk.no/playback";
pub struct NrkProvider {
client: Client,
}
impl NrkProvider {
pub fn new() -> Result<Self> {
let client = Client::builder().user_agent("nab/1.0").build()?;
Ok(Self { client })
}
fn extract_program_id(url_or_id: &str) -> String {
if url_or_id.starts_with("http") {
if let Some(program_id) = segment_after(url_or_id, "program") {
return program_id.to_string();
}
if url_or_id.contains("/serie/") && url_or_id.contains("/episode/") {
let parts: Vec<&str> = url_or_id.split('/').collect();
let serie_idx = parts.iter().position(|&p| p == "serie");
if let Some(idx) = serie_idx
&& idx + 5 < parts.len()
{
return format!(
"{}/s{}/e{}",
parts[idx + 1],
parts[idx + 3], strip_query(parts[idx + 5]) );
}
}
last_path_segment_without_query(url_or_id)
.unwrap_or(url_or_id)
.to_string()
} else {
url_or_id.to_string()
}
}
fn extract_series_id(url_or_id: &str) -> String {
if url_or_id.starts_with("http") {
segment_after(url_or_id, "serie")
.or_else(|| last_path_segment_without_query(url_or_id))
.unwrap_or(url_or_id)
.to_string()
} else {
url_or_id.to_string()
}
}
async fn fetch_playback_manifest(&self, program_id: &str) -> Result<NrkPlaybackResponse> {
let url = format!("{NRK_PLAYBACK_BASE}/manifest/program/{program_id}");
let resp = self
.client
.get(&url)
.header("Accept", "application/json")
.send()
.await
.with_context(|| format!("NRK playback API request failed for {program_id}"))?;
if !resp.status().is_success() {
return Err(anyhow!(
"NRK Playback API error: {} for program {}",
resp.status(),
program_id
));
}
resp.json()
.await
.context("Failed to parse NRK playback API response")
}
async fn fetch_program_metadata(&self, program_id: &str) -> Result<NrkProgramMetadata> {
let url = format!("{NRK_PSAPI_BASE}/tv/catalog/programs/{program_id}");
let resp = self
.client
.get(&url)
.header("Accept", "application/json")
.send()
.await
.with_context(|| format!("NRK PSAPI request failed for {program_id}"))?;
if !resp.status().is_success() {
return Err(anyhow!(
"NRK PSAPI error: {} for program {}",
resp.status(),
program_id
));
}
resp.json()
.await
.context("Failed to parse NRK program metadata response")
}
async fn fetch_series(&self, series_id: &str) -> Result<NrkSeriesResponse> {
let url = format!("{NRK_PSAPI_BASE}/tv/catalog/series/{series_id}");
let resp = self
.client
.get(&url)
.header("Accept", "application/json")
.send()
.await
.with_context(|| format!("NRK series API request failed for {series_id}"))?;
if !resp.status().is_success() {
return Err(anyhow!(
"NRK Series API error: {} for series {}",
resp.status(),
series_id
));
}
resp.json()
.await
.context("Failed to parse NRK series API response")
}
}
impl Default for NrkProvider {
fn default() -> Self {
Self::new().expect("Failed to create NrkProvider")
}
}
#[async_trait]
impl StreamProvider for NrkProvider {
fn name(&self) -> &'static str {
"nrk"
}
fn matches(&self, url: &str) -> bool {
url.contains("tv.nrk.no") || url.contains("nrk.no/tv") || url.contains("radio.nrk.no")
}
async fn get_stream_info(&self, id: &str) -> Result<StreamInfo> {
let program_id = Self::extract_program_id(id);
let playback = self.fetch_playback_manifest(&program_id).await?;
let playable = playback
.playable
.ok_or_else(|| anyhow!("No playable content found"))?;
let manifest_url = playable
.assets
.iter()
.find(|a| a.format == "HLS")
.map(|a| a.url.clone())
.ok_or_else(|| anyhow!("No HLS manifest found"))?;
let metadata = self.fetch_program_metadata(&program_id).await.ok();
let title = metadata
.as_ref()
.map_or_else(|| program_id.clone(), |m| m.titles.title.clone());
let description = metadata.as_ref().and_then(|m| m.titles.subtitle.clone());
let duration = playable.duration.map(|d| {
parse_iso8601_duration(&d).unwrap_or(0)
});
let thumbnail_url = metadata.as_ref().and_then(|m| {
m.image.as_ref().and_then(|img| {
img.web_images
.iter()
.find(|w| w.pixel_width >= 960)
.or(img.web_images.first())
.map(|w| w.image_url.clone())
})
});
let is_live = playable.live.unwrap_or(false);
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 id = Self::extract_series_id(series_id);
let series = self.fetch_series(&id).await?;
let mut episodes = Vec::new();
for season in series.seasons.unwrap_or_default() {
for episode in season.episodes.unwrap_or_default() {
episodes.push(EpisodeInfo {
id: episode.id,
title: episode.titles.title,
#[allow(clippy::cast_sign_loss)]
episode_number: episode.episode_number.map(|n| n as u32),
#[allow(clippy::cast_sign_loss)]
season_number: Some(season.season_number as u32),
duration_seconds: episode.duration.and_then(|d| parse_iso8601_duration(&d)),
publish_date: episode.availability.and_then(|a| a.published),
});
}
}
Ok(SeriesInfo {
id,
title: series.titles.title,
episodes,
})
}
}
fn parse_iso8601_duration(duration: &str) -> Option<u64> {
let duration = duration.trim_start_matches("PT");
let mut seconds: u64 = 0;
let mut current_num = String::new();
for c in duration.chars() {
if c.is_ascii_digit() {
current_num.push(c);
} else {
let num: u64 = current_num.parse().unwrap_or(0);
current_num.clear();
match c {
'H' => seconds += num * 3600,
'M' => seconds += num * 60,
'S' => seconds += num,
_ => {}
}
}
}
if seconds > 0 { Some(seconds) } else { None }
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct NrkPlaybackResponse {
playable: Option<NrkPlayable>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct NrkPlayable {
assets: Vec<NrkAsset>,
duration: Option<String>,
live: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct NrkAsset {
url: String,
format: String,
#[allow(dead_code)]
mime_type: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct NrkProgramMetadata {
titles: NrkTitles,
image: Option<NrkImage>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct NrkTitles {
title: String,
subtitle: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct NrkImage {
web_images: Vec<NrkWebImage>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct NrkWebImage {
image_url: String,
pixel_width: u32,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct NrkSeriesResponse {
titles: NrkTitles,
seasons: Option<Vec<NrkSeason>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct NrkSeason {
season_number: i32,
episodes: Option<Vec<NrkEpisode>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct NrkEpisode {
id: String,
titles: NrkTitles,
episode_number: Option<i32>,
duration: Option<String>,
availability: Option<NrkAvailability>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct NrkAvailability {
published: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_program_id() {
assert_eq!(
NrkProvider::extract_program_id("KMTE50001219"),
"KMTE50001219"
);
assert_eq!(
NrkProvider::extract_program_id("https://tv.nrk.no/program/KMTE50001219"),
"KMTE50001219"
);
assert_eq!(
NrkProvider::extract_program_id(
"https://tv.nrk.no/serie/nytt-paa-nytt/sesong/59/episode/7?autoplay=false"
),
"nytt-paa-nytt/s59/e7"
);
assert_eq!(
NrkProvider::extract_program_id(
"https://tv.nrk.no/serie/nytt-paa-nytt/sesong/59/episode"
),
"episode"
);
}
#[test]
fn test_extract_series_id() {
assert_eq!(
NrkProvider::extract_series_id("https://tv.nrk.no/serie/nytt-paa-nytt"),
"nytt-paa-nytt"
);
assert_eq!(
NrkProvider::extract_series_id("https://tv.nrk.no/serie/nytt-paa-nytt?autoplay=false"),
"nytt-paa-nytt"
);
assert_eq!(
NrkProvider::extract_series_id("https://radio.nrk.no/nytt-paa-nytt?autoplay=false"),
"nytt-paa-nytt"
);
}
#[test]
fn test_parse_iso8601_duration() {
assert_eq!(parse_iso8601_duration("PT1H"), Some(3600));
assert_eq!(parse_iso8601_duration("PT30M"), Some(1800));
assert_eq!(parse_iso8601_duration("PT1H30M"), Some(5400));
assert_eq!(parse_iso8601_duration("PT45M30S"), Some(2730));
}
#[test]
fn test_parse_iso8601_duration_seconds_only() {
assert_eq!(parse_iso8601_duration("PT90S"), Some(90));
}
#[test]
fn test_parse_iso8601_duration_full() {
assert_eq!(parse_iso8601_duration("PT2H15M30S"), Some(8130));
}
#[test]
fn test_parse_iso8601_duration_empty() {
assert_eq!(parse_iso8601_duration("PT"), None);
assert_eq!(parse_iso8601_duration("PT0S"), None);
}
#[test]
fn test_matches() {
let provider = NrkProvider::default();
assert!(provider.matches("https://tv.nrk.no/program/KMTE50001219"));
assert!(provider.matches("https://nrk.no/tv/program/KMTE50001219"));
assert!(provider.matches("https://radio.nrk.no/program/ABC123"));
assert!(!provider.matches("https://example.com"));
}
}