//! Embed podcast-spiller: GET /pub/{slug}/{episode_id}/player //! //! Serverer en selvstående HTML-side med podcast-spiller som kan embeddees //! som iframe. Inkluderer artwork, tittel, play/pause, progress, waveform //! (WaveSurfer.js) og kapittelmerkering. //! //! Ref: docs/concepts/publisering.md, docs/features/podcast_hosting.md use axum::{ extract::{Path, State}, http::{header, StatusCode}, response::Response, }; use chrono::{DateTime, Utc}; use serde::Serialize; use sqlx::PgPool; use uuid::Uuid; use crate::AppState; // ============================================================================= // Data-modeller // ============================================================================= #[derive(Serialize)] struct EpisodePlayerData { title: String, podcast_title: String, audio_url: String, artwork_url: Option, duration_secs: Option, chapters: Vec, base_url: String, } #[derive(Serialize)] struct ChapterData { at_secs: i64, at_display: String, title: String, } // ============================================================================= // Handler // ============================================================================= /// GET /pub/{slug}/{episode_id}/player — embed-klar podcast-spiller. pub async fn serve_player( State(state): State, Path((slug, episode_id)): Path<(String, String)>, ) -> Result { let data = fetch_episode_player_data(&state.db, &slug, &episode_id) .await .map_err(|e| { tracing::error!(slug = %slug, episode = %episode_id, error = %e, "Feil ved henting av episode for spiller"); StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; let html = render_player_html(&data); Ok(Response::builder() .header(header::CONTENT_TYPE, "text/html; charset=utf-8") .header(header::CACHE_CONTROL, "public, max-age=3600") // Tillat iframe-embedding fra alle domener .header("X-Frame-Options", "ALLOWALL") .header( "Content-Security-Policy", "frame-ancestors *; default-src 'self' 'unsafe-inline' https://unpkg.com; media-src 'self'; img-src 'self' data:", ) .body(html.into()) .unwrap()) } // ============================================================================= // Database-spørring // ============================================================================= async fn fetch_episode_player_data( db: &PgPool, slug: &str, episode_short_id: &str, ) -> Result, sqlx::Error> { // 1. Finn samling med publishing-trait let collection_row: Option<(Uuid, Option, serde_json::Value)> = sqlx::query_as( r#" SELECT id, title, metadata FROM nodes WHERE node_kind = 'collection' AND metadata->'traits'->'publishing'->>'slug' = $1 LIMIT 1 "#, ) .bind(slug) .fetch_optional(db) .await?; let Some((collection_id, collection_title, collection_metadata)) = collection_row else { return Ok(None); }; let podcast_title = collection_title.unwrap_or_else(|| slug.to_string()); // Base URL for CAS og lenker let base_url = collection_metadata .get("traits") .and_then(|t| t.get("publishing")) .and_then(|p| p.get("custom_domain")) .and_then(|d| d.as_str()) .map(|d| format!("https://{d}")) .unwrap_or_else(|| format!("https://synops.no/pub/{slug}")); // 2. Finn episoden (belongs_to samlingen, short_id-match) let pattern = format!("{episode_short_id}%"); let episode_row: Option<(Uuid, Option, serde_json::Value, DateTime, Option)> = sqlx::query_as( r#" SELECT n.id, n.title, n.metadata, n.created_at, e.metadata FROM edges e JOIN nodes n ON n.id = e.source_id WHERE e.target_id = $1 AND e.edge_type = 'belongs_to' AND n.id::text LIKE $2 LIMIT 1 "#, ) .bind(collection_id) .bind(&pattern) .fetch_optional(db) .await?; let Some((episode_id, episode_title, _episode_metadata, created_at, edge_meta)) = episode_row else { return Ok(None); }; // Sjekk publish_at — ikke server spilleren for upubliserte episoder let publish_at = edge_meta .as_ref() .and_then(|m| m.get("publish_at")) .and_then(|v| v.as_str()) .and_then(|s| s.parse::>().ok()) .unwrap_or(created_at); if publish_at > Utc::now() { return Ok(None); } // 3. Hent medienode (has_media-edge) for audio-URL og varighet let media_row: Option<(serde_json::Value,)> = sqlx::query_as( r#" SELECT m.metadata FROM edges e JOIN nodes m ON m.id = e.target_id WHERE e.source_id = $1 AND e.edge_type = 'has_media' AND m.node_kind = 'media' LIMIT 1 "#, ) .bind(episode_id) .fetch_optional(db) .await?; let Some((media_metadata,)) = media_row else { return Ok(None); // Ingen lydfil — ingenting å spille }; let cas_hash = media_metadata .get("cas_hash") .and_then(|v| v.as_str()) .ok_or_else(|| { tracing::warn!(episode_id = %episode_id, "Media-node mangler cas_hash"); sqlx::Error::RowNotFound })?; let audio_url = format!("{base_url}/cas/{cas_hash}"); let duration_secs = media_metadata .get("duration_secs") .and_then(|v| v.as_i64()); // 4. Hent artwork — episodens egen og fallback til samlingens let artwork_cas: Option = sqlx::query_scalar( r#" SELECT m.metadata->>'cas_hash' FROM edges e JOIN nodes m ON m.id = e.source_id WHERE e.target_id = $1 AND e.edge_type = 'og_image' LIMIT 1 "#, ) .bind(episode_id) .fetch_optional(db) .await?; // Fallback til samlingens artwork let artwork_cas = match artwork_cas { Some(h) => Some(h), None => { sqlx::query_scalar( r#" SELECT m.metadata->>'cas_hash' FROM edges e JOIN nodes m ON m.id = e.target_id WHERE e.source_id = $1 AND e.edge_type = 'og_image' LIMIT 1 "#, ) .bind(collection_id) .fetch_optional(db) .await? } }; let artwork_url = artwork_cas.map(|h| format!("{base_url}/cas/{h}")); // 5. Hent presentasjonselementer (tittel-edge) let pres_title: Option<(Option, Option)> = sqlx::query_as( r#" SELECT n.title, n.content FROM edges e JOIN nodes n ON n.id = e.source_id WHERE e.target_id = $1 AND e.edge_type = 'title' ORDER BY e.created_at DESC LIMIT 1 "#, ) .bind(episode_id) .fetch_optional(db) .await?; let title = pres_title .and_then(|(t, c)| t.or(c)) .or(episode_title) .unwrap_or_else(|| "Uten tittel".to_string()); // 6. Hent kapitler let chapter_rows: Vec<(String, Option)> = sqlx::query_as( r#" SELECT COALESCE(e.metadata->>'at', '00:00:00') AS at, e.metadata->>'title' AS title FROM edges e WHERE e.source_id = $1 AND e.edge_type = 'chapter' ORDER BY e.metadata->>'at' "#, ) .bind(episode_id) .fetch_all(db) .await?; let chapters: Vec = chapter_rows .into_iter() .map(|(at, ch_title)| { let secs = parse_time_to_secs(&at); ChapterData { at_secs: secs, at_display: at, title: ch_title.unwrap_or_else(|| format!("Kapittel")), } }) .collect(); Ok(Some(EpisodePlayerData { title, podcast_title, audio_url, artwork_url, duration_secs, chapters, base_url, })) } /// Parse "HH:MM:SS" eller "MM:SS" til sekunder. fn parse_time_to_secs(time_str: &str) -> i64 { let parts: Vec<&str> = time_str.split(':').collect(); match parts.len() { 3 => { let h: i64 = parts[0].parse().unwrap_or(0); let m: i64 = parts[1].parse().unwrap_or(0); let s: i64 = parts[2].parse().unwrap_or(0); h * 3600 + m * 60 + s } 2 => { let m: i64 = parts[0].parse().unwrap_or(0); let s: i64 = parts[1].parse().unwrap_or(0); m * 60 + s } _ => 0, } } /// Escape HTML-tekst. fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } /// Escape streng for JavaScript (inni enkle anførselstegn). fn js_escape(s: &str) -> String { s.replace('\\', "\\\\") .replace('\'', "\\'") .replace('\n', "\\n") .replace('\r', "") .replace(" String { let title = html_escape(&data.title); let podcast_title = html_escape(&data.podcast_title); let artwork_html = if let Some(ref url) = data.artwork_url { format!( r#"{}"#, html_escape(url), title ) } else { // Placeholder med podcast-ikon r#"
"#.to_string() }; let chapters_json = serde_json::to_string(&data.chapters).unwrap_or_else(|_| "[]".to_string()); let chapters_html = if data.chapters.is_empty() { String::new() } else { let mut html = String::from(r#"
Kapitler
"#); for (i, ch) in data.chapters.iter().enumerate() { html.push_str(&format!( r#""#, i, ch.at_secs, html_escape(&ch.at_display), html_escape(&ch.title), )); } html.push_str("
"); html }; let duration_secs = data.duration_secs.unwrap_or(0); format!( r##" {title} — {podcast_title}
{artwork_html}

{title}

{podcast_title}

0:00 0:00
{chapters_html}
"##, title = title, podcast_title = podcast_title, artwork_html = artwork_html, audio_url_js = js_escape(&data.audio_url), duration_secs = duration_secs, chapters_json = chapters_json, chapters_html = chapters_html, ) }