diff --git a/maskinrommet/src/embed_player.rs b/maskinrommet/src/embed_player.rs new file mode 100644 index 0000000..f791a6c --- /dev/null +++ b/maskinrommet/src/embed_player.rs @@ -0,0 +1,667 @@ +//! 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, + ) +} diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 26c5f1a..3220fc8 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -10,6 +10,7 @@ pub mod cas; pub mod cli_dispatch; pub mod clip; mod custom_domain; +mod embed_player; pub mod describe_image; mod intentions; pub mod jobs; @@ -273,6 +274,8 @@ async fn main() { .route("/pub/{slug}/sok", get(publishing::serve_search)) .route("/pub/{slug}/om", get(publishing::serve_about)) .route("/pub/{slug}/preview/{theme}", get(publishing::preview_theme)) + // Embed podcast-spiller (oppgave 30.5) + .route("/pub/{slug}/{episode_id}/player", get(embed_player::serve_player)) // NB: {article_id} catch-all må komme etter de spesifikke rutene .route("/pub/{slug}/{article_id}", get(publishing::serve_article)) // Custom domains: Caddy on-demand TLS callback diff --git a/tasks.md b/tasks.md index 7a4a867..74f06e9 100644 --- a/tasks.md +++ b/tasks.md @@ -430,8 +430,7 @@ prøveimport-flyt. - [x] 30.4 Statistikk-dashboard: vis nedlastinger per episode, trend over tid, topp-episoder, klienter (Apple/Spotify/andre), geografi. Integrert i admin-panelet. ### Embed-spiller -- [~] 30.5 Podcast-spiller komponent: Svelte-komponent med artwork, tittel, play/pause, progress, waveform, kapittelmerkering. Responsiv. Serveres som iframe-embed: `synops.no/pub///player`. - > Påbegynt: 2026-03-18T23:45 +- [x] 30.5 Podcast-spiller komponent: Svelte-komponent med artwork, tittel, play/pause, progress, waveform, kapittelmerkering. Responsiv. Serveres som iframe-embed: `synops.no/pub///player`. ### Import - [ ] 30.6 `synops-import-podcast` CLI: importer eksisterende podcast fra RSS-feed. Parse metadata, last ned lydfiler/artwork/transkripsjoner til CAS, opprett noder med edges. Duplikatdeteksjon via ``. `--dry-run` for forhåndsvisning. Idempotent: kjør flere ganger, bare nye episoder importeres.