Embed podcast-spiller: /pub/{slug}/{episode}/player (oppgave 30.5)

Ny maskinrommet-handler som serverer en selvstående HTML-side med
podcast-spiller, designet for iframe-embedding på eksterne nettsider.

Spilleren inkluderer:
- Artwork (episode-spesifikk med fallback til samlingens)
- Tittel og podcast-navn
- Play/pause med loading-spinner
- WaveSurfer.js waveform-visualisering (CDN)
- Tidsvisning (nåværende/total)
- Kapittelmerkering (visuelt på waveform + klikkbar liste)
- Responsiv design (mobil-vennlig ned til 360px)
- Iframe-vennlige headers (X-Frame-Options, CSP frame-ancestors)

Rute: GET /pub/{slug}/{episode_id}/player
Registrert før {article_id} catch-all i rutehierarkiet.
This commit is contained in:
vegard 2026-03-18 23:53:21 +00:00
parent 4b53adefa9
commit 4c1c470ed7
3 changed files with 671 additions and 2 deletions

View file

@ -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<String>,
duration_secs: Option<i64>,
chapters: Vec<ChapterData>,
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<AppState>,
Path((slug, episode_id)): Path<(String, String)>,
) -> Result<Response, StatusCode> {
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<Option<EpisodePlayerData>, sqlx::Error> {
// 1. Finn samling med publishing-trait
let collection_row: Option<(Uuid, Option<String>, 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<String>, serde_json::Value, DateTime<Utc>, Option<serde_json::Value>)> =
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::<DateTime<Utc>>().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<String> = 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<String>, Option<String>)> = 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<String>)> = 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<ChapterData> = 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
/// Escape streng for JavaScript (inni enkle anførselstegn).
fn js_escape(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('\'', "\\'")
.replace('\n', "\\n")
.replace('\r', "")
.replace("</", "<\\/")
}
// =============================================================================
// HTML-rendering
// =============================================================================
fn render_player_html(data: &EpisodePlayerData) -> 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#"<img class="artwork" src="{}" alt="{}" />"#,
html_escape(url),
title
)
} else {
// Placeholder med podcast-ikon
r#"<div class="artwork artwork-placeholder"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg></div>"#.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#"<div class="chapters" id="chapters"><div class="chapters-toggle" onclick="toggleChapters()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7"/></svg><span>Kapitler</span><svg class="chevron" id="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg></div><div class="chapters-list" id="chapters-list">"#);
for (i, ch) in data.chapters.iter().enumerate() {
html.push_str(&format!(
r#"<button class="chapter-btn" data-index="{}" onclick="seekToChapter({})"><span class="chapter-time">{}</span><span class="chapter-title">{}</span></button>"#,
i,
ch.at_secs,
html_escape(&ch.at_display),
html_escape(&ch.title),
));
}
html.push_str("</div></div>");
html
};
let duration_secs = data.duration_secs.unwrap_or(0);
format!(
r##"<!DOCTYPE html>
<html lang="no">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} {podcast_title}</title>
<style>
*,*::before,*::after{{box-sizing:border-box}}
body{{
margin:0;padding:0;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
background:#fff;color:#1a1a2e;
overflow-x:hidden;
}}
.player{{
display:flex;flex-direction:column;
max-width:640px;margin:0 auto;
padding:16px;gap:12px;
}}
/* Horisontal layout på bredere skjermer */
.player-top{{
display:flex;align-items:center;gap:16px;
}}
.artwork,.artwork-placeholder{{
width:80px;height:80px;min-width:80px;
border-radius:8px;object-fit:cover;
background:#e8e8f0;
}}
.artwork-placeholder{{
display:flex;align-items:center;justify-content:center;
color:#9ca3af;
}}
.artwork-placeholder svg{{width:36px;height:36px}}
.meta{{
flex:1;min-width:0;
}}
.meta h1{{
margin:0;font-size:16px;font-weight:600;
line-height:1.3;
overflow:hidden;text-overflow:ellipsis;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;
}}
.meta .podcast-name{{
margin:4px 0 0;font-size:12px;color:#6b7280;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}}
/* Kontroller */
.controls{{
display:flex;align-items:center;gap:12px;
}}
.play-btn{{
width:44px;height:44px;min-width:44px;
border:none;border-radius:50%;cursor:pointer;
background:#2563eb;color:#fff;
display:flex;align-items:center;justify-content:center;
transition:background .15s,transform .1s;
}}
.play-btn:hover{{background:#1d4ed8}}
.play-btn:active{{transform:scale(0.95)}}
.play-btn:disabled{{background:#d1d5db;cursor:not-allowed}}
.play-btn svg{{width:20px;height:20px}}
/* Waveform */
.waveform-wrap{{
flex:1;min-width:0;
}}
#waveform{{width:100%;height:48px}}
/* Tidsvisning */
.time-row{{
display:flex;justify-content:space-between;
font-size:11px;color:#9ca3af;
padding:0 2px;margin-top:-4px;
}}
/* Progressbar fallback (før waveform er klar) */
.progress-bar{{
width:100%;height:4px;background:#e5e7eb;border-radius:2px;
cursor:pointer;position:relative;overflow:hidden;
}}
.progress-bar .fill{{
height:100%;background:#2563eb;border-radius:2px;
transition:width .1s linear;
}}
/* Kapitler */
.chapters{{margin-top:4px}}
.chapters-toggle{{
display:flex;align-items:center;gap:6px;
font-size:13px;color:#6b7280;cursor:pointer;
padding:6px 0;border:none;background:none;width:100%;
}}
.chapters-toggle svg{{width:16px;height:16px}}
.chapters-toggle .chevron{{
margin-left:auto;transition:transform .2s;
}}
.chapters-toggle .chevron.open{{transform:rotate(180deg)}}
.chapters-list{{
display:none;flex-direction:column;gap:2px;
max-height:240px;overflow-y:auto;
padding:4px 0;
}}
.chapters-list.show{{display:flex}}
.chapter-btn{{
display:flex;align-items:center;gap:8px;
padding:8px 10px;border:none;background:none;
cursor:pointer;border-radius:6px;text-align:left;
font-size:13px;color:#374151;
transition:background .1s;
}}
.chapter-btn:hover{{background:#f3f4f6}}
.chapter-btn.active{{background:#eff6ff;color:#2563eb}}
.chapter-time{{
font-size:11px;color:#9ca3af;min-width:48px;
font-variant-numeric:tabular-nums;
}}
.chapter-title{{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
/* Loading-spinner */
.spinner{{
width:20px;height:20px;
border:2.5px solid rgba(255,255,255,.3);
border-top-color:#fff;border-radius:50%;
animation:spin .6s linear infinite;
}}
@keyframes spin{{to{{transform:rotate(360deg)}}}}
/* Feilmelding */
.error{{font-size:12px;color:#ef4444;padding:4px 0}}
/* Kapittelmerkører på waveform */
.chapter-marker{{
position:absolute;top:0;bottom:0;width:2px;
background:rgba(37,99,235,.4);pointer-events:none;
z-index:2;
}}
/* Responsiv: smalere skjermer */
@media(max-width:360px){{
.player{{padding:12px}}
.artwork,.artwork-placeholder{{width:64px;height:64px;min-width:64px}}
.meta h1{{font-size:14px}}
#waveform{{height:40px}}
.play-btn{{width:40px;height:40px;min-width:40px}}
}}
</style>
</head>
<body>
<div class="player">
<div class="player-top">
{artwork_html}
<div class="meta">
<h1>{title}</h1>
<p class="podcast-name">{podcast_title}</p>
</div>
</div>
<div class="controls">
<button class="play-btn" id="play-btn" disabled aria-label="Spill av">
<div class="spinner"></div>
</button>
<div class="waveform-wrap">
<div id="waveform"></div>
</div>
</div>
<div class="time-row">
<span id="current-time">0:00</span>
<span id="total-time">0:00</span>
</div>
{chapters_html}
</div>
<script src="https://unpkg.com/wavesurfer.js@7/dist/wavesurfer.min.js"></script>
<script>
(function() {{
'use strict';
var audioUrl = '{audio_url_js}';
var durationHint = {duration_secs};
var chapters = {chapters_json};
var playBtn = document.getElementById('play-btn');
var currentTimeEl = document.getElementById('current-time');
var totalTimeEl = document.getElementById('total-time');
var waveformEl = document.getElementById('waveform');
var playing = false;
var ready = false;
var ws = null;
function formatTime(s) {{
if (!s || !isFinite(s)) return '0:00';
var h = Math.floor(s / 3600);
var m = Math.floor((s % 3600) / 60);
var sec = Math.floor(s % 60);
if (h > 0) return h + ':' + String(m).padStart(2,'0') + ':' + String(sec).padStart(2,'0');
return m + ':' + String(sec).padStart(2,'0');
}}
if (durationHint > 0) {{
totalTimeEl.textContent = formatTime(durationHint);
}}
// Play/pause ikoner
var iconPlay = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
var iconPause = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg>';
function updatePlayBtn() {{
playBtn.innerHTML = playing ? iconPause : iconPlay;
playBtn.setAttribute('aria-label', playing ? 'Pause' : 'Spill av');
}}
try {{
ws = WaveSurfer.create({{
container: waveformEl,
height: 48,
waveColor: '#93c5fd',
progressColor: '#2563eb',
cursorColor: '#1d4ed8',
cursorWidth: 2,
barWidth: 2,
barGap: 1,
barRadius: 2,
normalize: true,
url: audioUrl,
mediaControls: false,
}});
ws.on('ready', function() {{
ready = true;
playBtn.disabled = false;
updatePlayBtn();
totalTimeEl.textContent = formatTime(ws.getDuration());
addChapterMarkers(ws.getDuration());
}});
ws.on('timeupdate', function(t) {{
currentTimeEl.textContent = formatTime(t);
updateActiveChapter(t);
}});
ws.on('play', function() {{ playing = true; updatePlayBtn(); }});
ws.on('pause', function() {{ playing = false; updatePlayBtn(); }});
ws.on('finish', function() {{ playing = false; updatePlayBtn(); }});
ws.on('error', function() {{
playBtn.disabled = false;
updatePlayBtn();
var err = document.createElement('p');
err.className = 'error';
err.textContent = 'Kunne ikke laste lydfilen';
waveformEl.parentNode.appendChild(err);
}});
}} catch(e) {{
// WaveSurfer ikke tilgjengelig — faller tilbake
console.warn('WaveSurfer feil:', e);
playBtn.disabled = false;
updatePlayBtn();
}}
playBtn.addEventListener('click', function() {{
if (ws) ws.playPause();
}});
// Kapitler
function addChapterMarkers(totalDur) {{
if (!chapters.length || !totalDur) return;
var container = waveformEl;
container.style.position = 'relative';
chapters.forEach(function(ch) {{
var pct = (ch.at_secs / totalDur) * 100;
if (pct > 0 && pct < 100) {{
var marker = document.createElement('div');
marker.className = 'chapter-marker';
marker.style.left = pct + '%';
container.appendChild(marker);
}}
}});
}}
function updateActiveChapter(t) {{
var btns = document.querySelectorAll('.chapter-btn');
var activeIdx = -1;
for (var i = chapters.length - 1; i >= 0; i--) {{
if (t >= chapters[i].at_secs) {{ activeIdx = i; break; }}
}}
btns.forEach(function(btn, i) {{
btn.classList.toggle('active', i === activeIdx);
}});
}}
// Globale funksjoner for onclick i HTML
window.seekToChapter = function(secs) {{
if (ws && ready) {{
ws.setTime(secs);
if (!playing) ws.play();
}}
}};
window.toggleChapters = function() {{
var list = document.getElementById('chapters-list');
var chevron = document.getElementById('chevron');
if (list) {{
list.classList.toggle('show');
if (chevron) chevron.classList.toggle('open');
}}
}};
}})();
</script>
</body>
</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,
)
}

View file

@ -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

View file

@ -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/<slug>/<episode>/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/<slug>/<episode>/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 `<guid>`. `--dry-run` for forhåndsvisning. Idempotent: kjør flere ganger, bare nye episoder importeres.