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:
parent
1b459948b9
commit
d7f08d439d
4 changed files with 448 additions and 46 deletions
|
|
@ -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 & 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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
3
tasks.md
3
tasks.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue