iTunes/Podcasting 2.0 RSS-tags: komplett implementering (oppgave 30.1)

Utvider synops-rss og maskinrommet/src/rss.rs med iTunes og Podcasting 2.0
namespace for podcast-samlinger.

Channel-level tags:
- itunes:author, itunes:category, itunes:explicit fra podcast-trait metadata
- itunes:image fra samlingens og_image-edge (CAS-hash)
- itunes:type (episodic)
- podcast:locked

Item-level tags:
- itunes:title, itunes:duration (fra media-metadata duration_secs)
- itunes:explicit (arver fra kanal), itunes:image (episode og_image)
- podcast:transcript (SRT-URL hvis transcription_segments finnes)
- podcast:chapters (JSON-URL hvis chapter-edges finnes)

DB-spørringene er utvidet til å hente transkripsjons-eksistens,
varighet, episode-bilde og kapitler i effektive batch-spørringer.

Merk: Transcript/chapters-URL-ene genereres i feeden men krever
offentlige endepunkt for å serveres (fremtidig oppgave).
This commit is contained in:
vegard 2026-03-18 23:12:34 +00:00
parent 1b459948b9
commit d7f08d439d
4 changed files with 448 additions and 46 deletions

View file

@ -21,23 +21,18 @@ med riktige tags. Vi har 80% allerede.
## Hva vi mangler
### 1. Podcast-spesifikke RSS-tags
### ~~1. Podcast-spesifikke RSS-tags~~ ✓
Utvid synops-rss med iTunes og Podcasting 2.0 namespace:
Implementert i synops-rss og maskinrommet/src/rss.rs. Begge genererer nå:
```xml
<itunes:author>Sidelinja</itunes:author>
<itunes:category text="News &amp; Politics"/>
<itunes:explicit>false</itunes:explicit>
<itunes:image href="https://synops.no/media/artwork.jpg"/>
<podcast:locked>no</podcast:locked>
<podcast:transcript url="https://synops.no/pub/sidelinja/ep42.srt"
type="application/srt"/>
<podcast:chapters url="https://synops.no/pub/sidelinja/ep42-chapters.json"
type="application/json+chapters"/>
```
**Channel-level:** `itunes:author`, `itunes:category`, `itunes:explicit`,
`itunes:image` (fra og_image-edge), `itunes:type`, `podcast:locked`.
Metadata fra samlingens podcast-trait:
**Item-level:** `itunes:title`, `itunes:duration`, `itunes:explicit`,
`itunes:image` (episode-bilde), `podcast:transcript` (SRT fra
transcription_segments), `podcast:chapters` (JSON fra chapter-edges).
Metadata leses fra samlingens podcast-trait:
```jsonc
{
@ -53,6 +48,10 @@ Metadata fra samlingens podcast-trait:
}
```
**Merk:** Transcript- og chapters-URL-ene (`/{short_id}/transcript.srt`,
`/{short_id}/chapters.json`) krever at offentlige endepunkt legges til i
maskinrommet for å servere disse. De genereres i feeden, men serveres ikke ennå.
### 2. Nedlastingsstatistikk
Caddy logger allerede alle requests. `synops-stats` parser

View file

@ -2,10 +2,12 @@
//!
//! Genererer RSS 2.0 eller Atom 1.0 feed for samlinger med `rss`-trait.
//! Feeden er offentlig — ingen autentisering kreves.
//! Podcast-samlinger (med `podcast`-trait) inkluderer <enclosure>-tags.
//! Podcast-samlinger (med `podcast`-trait) inkluderer <enclosure>-tags,
//! iTunes-tags og Podcasting 2.0-tags (transcript, chapters).
//!
//! Ref: docs/concepts/publisering.md (RSS/Atom-seksjonen)
//! docs/primitiver/traits.md (rss-trait)
//! docs/features/podcast_hosting.md (iTunes/Podcasting 2.0)
use axum::{
extract::{Path, State},
@ -32,6 +34,17 @@ struct RssTraitConfig {
language: Option<String>,
}
#[derive(Deserialize, Default)]
#[allow(dead_code)]
struct PodcastTraitConfig {
itunes_author: Option<String>,
itunes_category: Option<String>,
explicit: Option<bool>,
language: Option<String>,
#[serde(default)]
redirect_feed: Option<String>,
}
#[derive(Deserialize, Default)]
struct PublishingTraitConfig {
#[allow(dead_code)]
@ -49,6 +62,8 @@ struct CollectionInfo {
rss_config: RssTraitConfig,
publishing_config: PublishingTraitConfig,
is_podcast: bool,
podcast_config: PodcastTraitConfig,
artwork_cas_hash: Option<String>,
}
struct FeedItem {
@ -61,6 +76,16 @@ struct FeedItem {
enclosure_url: Option<String>,
enclosure_mime: Option<String>,
enclosure_size: Option<i64>,
has_transcript: bool,
chapters: Vec<Chapter>,
duration_secs: Option<i64>,
episode_image_cas: Option<String>,
}
#[allow(dead_code)]
struct Chapter {
at: String,
title: Option<String>,
}
// =============================================================================
@ -164,17 +189,40 @@ async fn find_collection_by_slug(
let is_podcast = traits.get("podcast").is_some();
let podcast_config: PodcastTraitConfig = traits
.get("podcast")
.cloned()
.map(|v| serde_json::from_value(v).unwrap_or_default())
.unwrap_or_default();
// Hent samlingens artwork via og_image-edge
let artwork_cas_hash: Option<String> = 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(id)
.fetch_optional(db)
.await?;
Ok(Some(CollectionInfo {
id,
title,
rss_config,
publishing_config,
is_podcast,
podcast_config,
artwork_cas_hash,
}))
}
/// Hent publiserte elementer (belongs_to-edges til samlingen).
/// For podcast-samlinger: inkluder enclosure-data via has_media-edges.
/// For podcast-samlinger: inkluder enclosure-data, transkripsjoner, kapitler og varighet.
async fn fetch_feed_items(
db: &PgPool,
collection_id: Uuid,
@ -182,8 +230,19 @@ async fn fetch_feed_items(
is_podcast: bool,
) -> Result<Vec<FeedItem>, sqlx::Error> {
if is_podcast {
// Podcast: join med has_media for enclosure-data
let rows: Vec<(Uuid, Option<String>, Option<String>, DateTime<Utc>, Option<serde_json::Value>, Option<String>, Option<String>, Option<i64>)> = sqlx::query_as(
let rows: Vec<(
Uuid,
Option<String>,
Option<String>,
DateTime<Utc>,
Option<serde_json::Value>,
Option<String>,
Option<String>,
Option<i64>,
Option<bool>,
Option<i64>,
Option<String>,
)> = sqlx::query_as(
r#"
SELECT
n.id,
@ -193,7 +252,17 @@ async fn fetch_feed_items(
e.metadata,
m.metadata->>'cas_hash' AS cas_hash,
m.metadata->>'mime' AS mime,
COALESCE((m.metadata->>'size_bytes')::bigint, (m.metadata->>'size')::bigint) AS size
COALESCE((m.metadata->>'size_bytes')::bigint, (m.metadata->>'size')::bigint) AS size,
EXISTS(
SELECT 1 FROM transcription_segments ts WHERE ts.node_id = n.id LIMIT 1
) AS has_transcript,
(m.metadata->>'duration_secs')::bigint AS duration_secs,
(SELECT img.metadata->>'cas_hash'
FROM edges ie
JOIN nodes img ON img.id = ie.target_id
WHERE ie.source_id = n.id AND ie.edge_type = 'og_image'
LIMIT 1
) AS episode_image_cas
FROM edges e
JOIN nodes n ON n.id = e.source_id
LEFT JOIN edges me ON me.source_id = n.id AND me.edge_type = 'has_media'
@ -212,9 +281,43 @@ async fn fetch_feed_items(
.fetch_all(db)
.await?;
// Samle node-IDer for å hente kapitler i én spørring
let node_ids: Vec<Uuid> = rows.iter().map(|r| r.0).collect();
let chapter_rows: Vec<(Uuid, String, Option<String>)> = if !node_ids.is_empty() {
sqlx::query_as(
r#"
SELECT
e.source_id,
COALESCE(e.metadata->>'at', '00:00:00') AS at,
e.metadata->>'title' AS title
FROM edges e
WHERE e.source_id = ANY($1)
AND e.edge_type = 'chapter'
ORDER BY e.source_id, e.metadata->>'at'
"#,
)
.bind(&node_ids)
.fetch_all(db)
.await?
} else {
vec![]
};
// Grupper kapitler per node
let mut chapters_map: std::collections::HashMap<Uuid, Vec<Chapter>> =
std::collections::HashMap::new();
for (node_id, at, title) in chapter_rows {
chapters_map
.entry(node_id)
.or_default()
.push(Chapter { at, title });
}
Ok(rows
.into_iter()
.map(|(id, title, content, created_at, edge_meta, cas_hash, mime, size)| {
.map(
|(id, title, content, created_at, edge_meta, cas_hash, mime, size, has_transcript, duration_secs, episode_image_cas)| {
let publish_at = edge_meta
.as_ref()
.and_then(|m| m.get("publish_at"))
@ -230,8 +333,13 @@ async fn fetch_feed_items(
enclosure_url: cas_hash.map(|h| format!("/cas/{h}")),
enclosure_mime: mime,
enclosure_size: size,
has_transcript: has_transcript.unwrap_or(false),
chapters: chapters_map.remove(&id).unwrap_or_default(),
duration_secs,
episode_image_cas,
}
})
},
)
.collect())
} else {
// Vanlig feed: kun noder, ingen enclosures
@ -277,6 +385,10 @@ async fn fetch_feed_items(
enclosure_url: None,
enclosure_mime: None,
enclosure_size: None,
has_transcript: false,
chapters: vec![],
duration_secs: None,
episode_image_cas: None,
}
})
.collect())
@ -304,19 +416,20 @@ fn build_rss_feed(collection: &CollectionInfo, items: &[FeedItem], base_url: &st
.as_deref()
.unwrap_or(""),
);
// Podcast-trait language overstyrer rss-trait language
let language = collection
.rss_config
.podcast_config
.language
.as_deref()
.or(collection.rss_config.language.as_deref())
.unwrap_or("no");
let feed_url = format!("{base_url}/feed.xml");
let mut xml = String::with_capacity(4096);
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
// iTunes namespace for podcast-feeds
if collection.is_podcast {
xml.push_str("<rss version=\"2.0\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n");
xml.push_str("<rss version=\"2.0\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\" xmlns:podcast=\"https://podcastindex.org/namespace/1.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n");
} else {
xml.push_str("<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n");
}
@ -335,6 +448,43 @@ fn build_rss_feed(collection: &CollectionInfo, items: &[FeedItem], base_url: &st
xml.push_str(&format!(" <lastBuildDate>{}</lastBuildDate>\n", date.to_rfc2822()));
}
// iTunes og Podcasting 2.0 channel-level tags
if collection.is_podcast {
let pc = &collection.podcast_config;
if let Some(ref author) = pc.itunes_author {
xml.push_str(&format!(
" <itunes:author>{}</itunes:author>\n",
xml_escape(author)
));
}
if let Some(ref category) = pc.itunes_category {
xml.push_str(&format!(
" <itunes:category text=\"{}\"/>\n",
xml_escape(category)
));
}
let explicit = pc.explicit.unwrap_or(false);
xml.push_str(&format!(
" <itunes:explicit>{}</itunes:explicit>\n",
if explicit { "true" } else { "false" }
));
if let Some(ref cas_hash) = collection.artwork_cas_hash {
let artwork_url = format!("{base_url}/cas/{cas_hash}");
xml.push_str(&format!(
" <itunes:image href=\"{artwork_url}\"/>\n"
));
}
xml.push_str(" <itunes:type>episodic</itunes:type>\n");
// Podcasting 2.0: locked
xml.push_str(" <podcast:locked>no</podcast:locked>\n");
}
for item in items {
xml.push_str(" <item>\n");
let title = xml_escape(item.title.as_deref().unwrap_or("Uten tittel"));
@ -367,6 +517,57 @@ fn build_rss_feed(collection: &CollectionInfo, items: &[FeedItem], base_url: &st
));
}
// iTunes og Podcasting 2.0 item-level tags (kun for podcast)
if collection.is_podcast {
xml.push_str(&format!(" <itunes:title>{title}</itunes:title>\n"));
if let Some(secs) = item.duration_secs {
let h = secs / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
if h > 0 {
xml.push_str(&format!(
" <itunes:duration>{h:02}:{m:02}:{s:02}</itunes:duration>\n"
));
} else {
xml.push_str(&format!(
" <itunes:duration>{m:02}:{s:02}</itunes:duration>\n"
));
}
}
let explicit = collection.podcast_config.explicit.unwrap_or(false);
xml.push_str(&format!(
" <itunes:explicit>{}</itunes:explicit>\n",
if explicit { "true" } else { "false" }
));
if let Some(ref cas) = item.episode_image_cas {
let img_url = format!("{base_url}/cas/{cas}");
xml.push_str(&format!(
" <itunes:image href=\"{img_url}\"/>\n"
));
}
// podcast:transcript — SRT fra transkripsjons-segmenter
if item.has_transcript {
let transcript_url =
format!("{base_url}/{}/transcript.srt", short_id(item.id));
xml.push_str(&format!(
" <podcast:transcript url=\"{transcript_url}\" type=\"application/srt\"/>\n"
));
}
// podcast:chapters — JSON fra chapter-edges
if !item.chapters.is_empty() {
let chapters_url =
format!("{base_url}/{}/chapters.json", short_id(item.id));
xml.push_str(&format!(
" <podcast:chapters url=\"{chapters_url}\" type=\"application/json+chapters\"/>\n"
));
}
}
xml.push_str(" </item>\n");
}

View file

@ -422,8 +422,7 @@ Ingen castopod, ingen ekstern tjeneste. Import fra eksisterende podcast med
prøveimport-flyt.
### RSS og metadata
- [~] 30.1 iTunes/Podcasting 2.0 RSS-tags: utvid synops-rss med `<itunes:*>` og `<podcast:*>` namespace. Tags fra samlingens podcast-trait metadata (author, category, explicit, language). Podcast:transcript og podcast:chapters fra eksisterende edges.
> Påbegynt: 2026-03-18T23:05
- [x] 30.1 iTunes/Podcasting 2.0 RSS-tags: utvid synops-rss med `<itunes:*>` og `<podcast:*>` namespace. Tags fra samlingens podcast-trait metadata (author, category, explicit, language). Podcast:transcript og podcast:chapters fra eksisterende edges.
- [ ] 30.2 Podcast-trait metadata: utvid podcast-trait med iTunes-felt (itunes_category, itunes_author, explicit, language, redirect_feed). Admin-UI for å redigere.
### Statistikk

View file

@ -53,6 +53,17 @@ struct RssTraitConfig {
language: Option<String>,
}
#[derive(Deserialize, Default)]
#[allow(dead_code)]
struct PodcastTraitConfig {
itunes_author: Option<String>,
itunes_category: Option<String>,
explicit: Option<bool>,
language: Option<String>,
#[serde(default)]
redirect_feed: Option<String>,
}
#[derive(Deserialize, Default)]
struct PublishingTraitConfig {
slug: Option<String>,
@ -70,6 +81,9 @@ struct CollectionInfo {
rss_config: RssTraitConfig,
publishing_config: PublishingTraitConfig,
is_podcast: bool,
podcast_config: PodcastTraitConfig,
/// CAS-hash for samlingens artwork (fra og_image-edge)
artwork_cas_hash: Option<String>,
}
struct FeedItem {
@ -81,6 +95,20 @@ struct FeedItem {
enclosure_url: Option<String>,
enclosure_mime: Option<String>,
enclosure_size: Option<i64>,
/// Episoden har transkripsjons-segmenter
has_transcript: bool,
/// Kapittelmarkører (tidspunkt + tittel)
chapters: Vec<Chapter>,
/// Varighet i sekunder (fra media-metadata)
duration_secs: Option<i64>,
/// Episodens artwork CAS-hash (fra og_image-edge)
episode_image_cas: Option<String>,
}
#[allow(dead_code)]
struct Chapter {
at: String,
title: Option<String>,
}
// =============================================================================
@ -230,6 +258,27 @@ async fn find_collection(
let is_podcast = traits.get("podcast").is_some();
let podcast_config: PodcastTraitConfig = traits
.get("podcast")
.cloned()
.map(|v| serde_json::from_value(v).unwrap_or_default())
.unwrap_or_default();
// Hent samlingens artwork via og_image-edge
let artwork_cas_hash: Option<String> = 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(id)
.fetch_optional(db)
.await?;
Ok(Some(CollectionInfo {
id,
title,
@ -237,11 +286,13 @@ async fn find_collection(
rss_config,
publishing_config,
is_podcast,
podcast_config,
artwork_cas_hash,
}))
}
/// Hent publiserte elementer (belongs_to-edges til samlingen).
/// For podcast-samlinger: inkluder enclosure-data via has_media-edges.
/// For podcast-samlinger: inkluder enclosure-data, transkripsjoner, kapitler og varighet.
async fn fetch_feed_items(
db: &sqlx::PgPool,
collection_id: Uuid,
@ -249,6 +300,7 @@ async fn fetch_feed_items(
is_podcast: bool,
) -> Result<Vec<FeedItem>, sqlx::Error> {
if is_podcast {
// Podcast: join med has_media for enclosure, sjekk transkripsjoner, varighet og episode-bilde
let rows: Vec<(
Uuid,
Option<String>,
@ -258,6 +310,9 @@ async fn fetch_feed_items(
Option<String>,
Option<String>,
Option<i64>,
Option<bool>,
Option<i64>,
Option<String>,
)> = sqlx::query_as(
r#"
SELECT
@ -268,7 +323,17 @@ async fn fetch_feed_items(
e.metadata,
m.metadata->>'cas_hash' AS cas_hash,
m.metadata->>'mime' AS mime,
COALESCE((m.metadata->>'size_bytes')::bigint, (m.metadata->>'size')::bigint) AS size
COALESCE((m.metadata->>'size_bytes')::bigint, (m.metadata->>'size')::bigint) AS size,
EXISTS(
SELECT 1 FROM transcription_segments ts WHERE ts.node_id = n.id LIMIT 1
) AS has_transcript,
(m.metadata->>'duration_secs')::bigint AS duration_secs,
(SELECT img.metadata->>'cas_hash'
FROM edges ie
JOIN nodes img ON img.id = ie.target_id
WHERE ie.source_id = n.id AND ie.edge_type = 'og_image'
LIMIT 1
) AS episode_image_cas
FROM edges e
JOIN nodes n ON n.id = e.source_id
LEFT JOIN edges me ON me.source_id = n.id AND me.edge_type = 'has_media'
@ -287,10 +352,43 @@ async fn fetch_feed_items(
.fetch_all(db)
.await?;
// Samle node-IDer for å hente kapitler i én spørring
let node_ids: Vec<Uuid> = rows.iter().map(|r| r.0).collect();
let chapter_rows: Vec<(Uuid, String, Option<String>)> = if !node_ids.is_empty() {
sqlx::query_as(
r#"
SELECT
e.source_id,
COALESCE(e.metadata->>'at', '00:00:00') AS at,
e.metadata->>'title' AS title
FROM edges e
WHERE e.source_id = ANY($1)
AND e.edge_type = 'chapter'
ORDER BY e.source_id, e.metadata->>'at'
"#,
)
.bind(&node_ids)
.fetch_all(db)
.await?
} else {
vec![]
};
// Grupper kapitler per node
let mut chapters_map: std::collections::HashMap<Uuid, Vec<Chapter>> =
std::collections::HashMap::new();
for (node_id, at, title) in chapter_rows {
chapters_map
.entry(node_id)
.or_default()
.push(Chapter { at, title });
}
Ok(rows
.into_iter()
.map(
|(id, title, content, created_at, edge_meta, cas_hash, mime, size)| {
|(id, title, content, created_at, edge_meta, cas_hash, mime, size, has_transcript, duration_secs, episode_image_cas)| {
let publish_at = edge_meta
.as_ref()
.and_then(|m| m.get("publish_at"))
@ -306,6 +404,10 @@ async fn fetch_feed_items(
enclosure_url: cas_hash.map(|h| format!("/cas/{h}")),
enclosure_mime: mime,
enclosure_size: size,
has_transcript: has_transcript.unwrap_or(false),
chapters: chapters_map.remove(&id).unwrap_or_default(),
duration_secs,
episode_image_cas,
}
},
)
@ -359,6 +461,10 @@ async fn fetch_feed_items(
enclosure_url: None,
enclosure_mime: None,
enclosure_size: None,
has_transcript: false,
chapters: vec![],
duration_secs: None,
episode_image_cas: None,
}
})
.collect())
@ -386,10 +492,12 @@ fn build_rss_feed(collection: &CollectionInfo, items: &[FeedItem], base_url: &st
.as_deref()
.unwrap_or(""),
);
// Podcast-trait language overstyre rss-trait language
let language = collection
.rss_config
.podcast_config
.language
.as_deref()
.or(collection.rss_config.language.as_deref())
.unwrap_or("no");
let feed_url = format!("{base_url}/feed.xml");
@ -397,7 +505,7 @@ fn build_rss_feed(collection: &CollectionInfo, items: &[FeedItem], base_url: &st
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
if collection.is_podcast {
xml.push_str("<rss version=\"2.0\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n");
xml.push_str("<rss version=\"2.0\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\" xmlns:podcast=\"https://podcastindex.org/namespace/1.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n");
} else {
xml.push_str("<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n");
}
@ -419,6 +527,46 @@ fn build_rss_feed(collection: &CollectionInfo, items: &[FeedItem], base_url: &st
));
}
// iTunes og Podcasting 2.0 channel-level tags
if collection.is_podcast {
let pc = &collection.podcast_config;
if let Some(ref author) = pc.itunes_author {
xml.push_str(&format!(
" <itunes:author>{}</itunes:author>\n",
xml_escape(author)
));
}
if let Some(ref category) = pc.itunes_category {
xml.push_str(&format!(
" <itunes:category text=\"{}\"/>\n",
xml_escape(category)
));
}
let explicit = pc.explicit.unwrap_or(false);
xml.push_str(&format!(
" <itunes:explicit>{}</itunes:explicit>\n",
if explicit { "true" } else { "false" }
));
// Artwork: fra og_image-edge eller samlingens podcast-bilde
if let Some(ref cas_hash) = collection.artwork_cas_hash {
let artwork_url = format!("{base_url}/cas/{cas_hash}");
xml.push_str(&format!(
" <itunes:image href=\"{artwork_url}\"/>\n"
));
}
xml.push_str(&format!(
" <itunes:type>episodic</itunes:type>\n"
));
// Podcasting 2.0: locked
xml.push_str(" <podcast:locked>no</podcast:locked>\n");
}
for item in items {
xml.push_str(" <item>\n");
let title = xml_escape(item.title.as_deref().unwrap_or("Uten tittel"));
@ -452,6 +600,61 @@ fn build_rss_feed(collection: &CollectionInfo, items: &[FeedItem], base_url: &st
));
}
// iTunes og Podcasting 2.0 item-level tags (kun for podcast)
if collection.is_podcast {
// itunes:title (samme som title, men eksplisitt for iTunes)
xml.push_str(&format!(" <itunes:title>{title}</itunes:title>\n"));
// itunes:duration
if let Some(secs) = item.duration_secs {
let h = secs / 3600;
let m = (secs % 3600) / 60;
let s = secs % 60;
if h > 0 {
xml.push_str(&format!(
" <itunes:duration>{h:02}:{m:02}:{s:02}</itunes:duration>\n"
));
} else {
xml.push_str(&format!(
" <itunes:duration>{m:02}:{s:02}</itunes:duration>\n"
));
}
}
// itunes:explicit (per episode, arver fra kanal)
let explicit = collection.podcast_config.explicit.unwrap_or(false);
xml.push_str(&format!(
" <itunes:explicit>{}</itunes:explicit>\n",
if explicit { "true" } else { "false" }
));
// Episode-bilde
if let Some(ref cas) = item.episode_image_cas {
let img_url = format!("{base_url}/cas/{cas}");
xml.push_str(&format!(
" <itunes:image href=\"{img_url}\"/>\n"
));
}
// podcast:transcript — SRT fra transkripsjons-segmenter
if item.has_transcript {
let transcript_url =
format!("{base_url}/{}/transcript.srt", short_id(item.id));
xml.push_str(&format!(
" <podcast:transcript url=\"{transcript_url}\" type=\"application/srt\"/>\n"
));
}
// podcast:chapters — JSON fra chapter-edges
if !item.chapters.is_empty() {
let chapters_url =
format!("{base_url}/{}/chapters.json", short_id(item.id));
xml.push_str(&format!(
" <podcast:chapters url=\"{chapters_url}\" type=\"application/json+chapters\"/>\n"
));
}
}
xml.push_str(" </item>\n");
}