From 2692fa2c9ef6ca29657cf23d5305733b252ad2ea Mon Sep 17 00:00:00 2001 From: Luna Date: Sun, 7 Jun 2026 07:49:48 -0700 Subject: [PATCH] Podcast / RSS feeds for the library (web, part 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subscribe to the whole library or a single channel in any podcast/media app. - src/feed.rs: pure RSS 2.0 + iTunes-namespace renderer with absolute enclosure URLs, MIME-by-extension, HH:MM:SS durations, and a chrono-free RFC-2822 date (civil-date algorithm). Unit-tested (mime / escape / duration / date / render). - web.rs: GET /feed.xml (whole library, newest first, capped at 300) and GET /feed/:platform/:handle (one channel); /api/feed-info returns the token for the UI. Enclosure + thumbnail URLs are absolute (derived from Host + X-Forwarded-Proto) and carry the feed token. - Auth: a stable read-only `feed_token` (persisted setting) lets a tokenized `?token=` GET reach /feed*, /files, and /music-files even when a password is set β€” podcast clients can't do the cookie login. Scoped to reads of feeds + media; never /api mutations. - Web UI: a "πŸ“‘ Podcast feed" section in Settings (library URL + Copy) and a "πŸ“‘ Feed URL" button in each channel's options dialog. Integration tests cover the served RSS (enclosure path, MIME, pubDate), the channel feed + 404, and the full token gate (401 without token once a password is set, 200 with it, /files reachable with it). 127 tests pass. Desktop URL display follows. Co-Authored-By: Claude Opus 4.8 --- src/feed.rs | 242 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/web.rs | 147 +++++++++++++++++++++++++ src/web_ui/index.html | 29 +++++ tests/api.rs | 61 +++++++++++ 5 files changed, 480 insertions(+) create mode 100644 src/feed.rs diff --git a/src/feed.rs b/src/feed.rs new file mode 100644 index 0000000..efc3326 --- /dev/null +++ b/src/feed.rs @@ -0,0 +1,242 @@ +//! RSS 2.0 + iTunes podcast feed rendering for the library. +//! +//! Turns a set of archived videos into a podcast/media feed any podcast app +//! can subscribe to. Pure string-building β€” the web layer ([`crate::web`]) +//! gathers the items from the live library and serves the result; everything +//! here is deterministic and unit-tested. +//! +//! Enclosure URLs are absolute (`scheme://host/files/...`) because podcast +//! clients fetch media out-of-band, with no notion of the page they came from. + +/// One episode in the feed. +pub struct FeedItem { + pub title: String, + pub description: String, + /// Absolute URL to the media file (the `/files/...` mount). + pub enclosure_url: String, + pub enclosure_len: u64, + pub enclosure_type: String, + /// Stable GUID β€” the yt-dlp video id. + pub guid: String, + /// RFC-2822 publication date. + pub pub_date: String, + /// `HH:MM:SS`, or empty when unknown. + pub duration: String, + /// Absolute thumbnail URL, or empty. + pub image_url: String, +} + +/// Feed-level metadata wrapping the items. +pub struct Feed { + pub title: String, + pub description: String, + /// Absolute link back to the web UI. + pub link: String, + pub items: Vec, +} + +/// MIME type for a media file, by extension. Falls back to a generic +/// `application/octet-stream` so an unknown container still produces a valid +/// enclosure. +pub fn mime_for(path: &std::path::Path) -> &'static str { + match path.extension().and_then(|e| e.to_str()).map(|s| s.to_ascii_lowercase()).as_deref() { + Some("mp4") | Some("m4v") => "video/mp4", + Some("mkv") => "video/x-matroska", + Some("webm") => "video/webm", + Some("mov") => "video/quicktime", + Some("avi") => "video/x-msvideo", + Some("ts") => "video/mp2t", + Some("m4a") => "audio/mp4", + Some("mp3") => "audio/mpeg", + Some("opus") | Some("ogg") => "audio/ogg", + Some("flac") => "audio/flac", + Some("wav") => "audio/wav", + _ => "application/octet-stream", + } +} + +/// Escape the five XML special characters for safe inclusion in element text +/// or attribute values. +pub fn xml_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + // Strip control chars that are illegal in XML 1.0 (e.g. stray + // bytes from a mangled title), keeping tab/newline/return. + c if (c as u32) < 0x20 && c != '\t' && c != '\n' && c != '\r' => {} + c => out.push(c), + } + } + out +} + +/// Format a duration in seconds as `H:MM:SS` (or `M:SS`). Empty for `None`. +pub fn fmt_duration(secs: Option) -> String { + let Some(s) = secs else { return String::new() }; + if !s.is_finite() || s < 0.0 { return String::new(); } + let total = s as u64; + let (h, m, sec) = (total / 3600, (total % 3600) / 60, total % 60); + if h > 0 { format!("{h}:{m:02}:{sec:02}") } else { format!("{m}:{sec:02}") } +} + +/// Convert yt-dlp's `YYYYMMDD` upload date (or a UNIX mtime fallback) into an +/// RFC-2822 date string for ``. Defaults to the Unix epoch when both +/// are missing so every item still has a valid, stable date. +pub fn pub_date(upload_date: Option<&str>, mtime_unix: Option) -> String { + if let Some(d) = upload_date { + if d.len() == 8 && d.chars().all(|c| c.is_ascii_digit()) { + // Treat as midnight UTC on that calendar day. + if let Ok(days) = ymd_to_unix_days(&d[0..4], &d[4..6], &d[6..8]) { + return rfc2822(days * 86_400); + } + } + } + rfc2822(mtime_unix.unwrap_or(0) as i64) +} + +/// Days since the Unix epoch for a Y/M/D, via a standard civil-date algorithm +/// (Howard Hinnant's `days_from_civil`). Avoids a chrono dependency. +fn ymd_to_unix_days(y: &str, m: &str, d: &str) -> Result { + let y: i64 = y.parse().map_err(|_| ())?; + let m: i64 = m.parse().map_err(|_| ())?; + let d: i64 = d.parse().map_err(|_| ())?; + if !(1..=12).contains(&m) || !(1..=31).contains(&d) { return Err(()); } + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = (y - era * 400) as i64; + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + Ok(era * 146_097 + doe - 719_468) +} + +/// Format a UNIX timestamp as an RFC-2822 date in GMT (e.g. +/// `Sun, 07 Jun 2026 00:00:00 GMT`). Self-contained, no chrono. +fn rfc2822(ts: i64) -> String { + const WD: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"]; // epoch = Thursday + const MON: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + let days = ts.div_euclid(86_400); + let secs = ts.rem_euclid(86_400); + let (h, mi, s) = (secs / 3600, (secs % 3600) / 60, secs % 60); + let wd = WD[(days.rem_euclid(7)) as usize]; + let (y, m, d) = civil_from_days(days); + format!("{wd}, {d:02} {mon} {y:04} {h:02}:{mi:02}:{s:02} GMT", mon = MON[(m - 1) as usize]) +} + +/// Inverse of [`ymd_to_unix_days`] β€” civil date from days-since-epoch. +fn civil_from_days(z: i64) -> (i64, i64, i64) { + let z = z + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (if m <= 2 { y + 1 } else { y }, m, d) +} + +/// Render the feed as an RSS 2.0 document with the iTunes podcast namespace. +pub fn render(feed: &Feed) -> String { + let mut s = String::with_capacity(1024 + feed.items.len() * 512); + s.push_str(r#""#); + s.push('\n'); + s.push_str(r#""#); + s.push_str("\n\n"); + s.push_str(&format!("{}\n", xml_escape(&feed.title))); + s.push_str(&format!("{}\n", xml_escape(&feed.link))); + s.push_str(&format!("{}\n", xml_escape(&feed.description))); + s.push_str("en\n"); + s.push_str("no\n"); + for it in &feed.items { + s.push_str("\n"); + s.push_str(&format!("{}\n", xml_escape(&it.title))); + s.push_str(&format!("{}\n", xml_escape(&it.description))); + s.push_str(&format!( + "\n", + xml_escape(&it.enclosure_url), it.enclosure_len, xml_escape(&it.enclosure_type), + )); + s.push_str(&format!("{}\n", xml_escape(&it.guid))); + s.push_str(&format!("{}\n", xml_escape(&it.pub_date))); + if !it.duration.is_empty() { + s.push_str(&format!("{}\n", xml_escape(&it.duration))); + } + if !it.image_url.is_empty() { + s.push_str(&format!("\n", xml_escape(&it.image_url))); + } + s.push_str("\n"); + } + s.push_str("\n\n"); + s +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn mime_by_extension() { + assert_eq!(mime_for(Path::new("a.mp4")), "video/mp4"); + assert_eq!(mime_for(Path::new("a.MKV")), "video/x-matroska"); + assert_eq!(mime_for(Path::new("a.opus")), "audio/ogg"); + assert_eq!(mime_for(Path::new("a.weird")), "application/octet-stream"); + } + + #[test] + fn escapes_xml() { + assert_eq!(xml_escape("a & b \"d\" 'e'"), "a & b <c> "d" 'e'"); + assert_eq!(xml_escape("tab\tkeep\u{0}drop"), "tab\tkeepdrop"); + } + + #[test] + fn duration_formats() { + assert_eq!(fmt_duration(Some(65.0)), "1:05"); + assert_eq!(fmt_duration(Some(3661.0)), "1:01:01"); + assert_eq!(fmt_duration(None), ""); + } + + #[test] + fn upload_date_to_rfc2822() { + // 2024-01-02 β†’ a Tuesday. + assert_eq!(pub_date(Some("20240102"), None), "Tue, 02 Jan 2024 00:00:00 GMT"); + // The Unix epoch is a Thursday. + assert_eq!(pub_date(None, Some(0)), "Thu, 01 Jan 1970 00:00:00 GMT"); + // Garbage upload date falls back to the mtime. + assert_eq!(pub_date(Some("notadate"), Some(0)), "Thu, 01 Jan 1970 00:00:00 GMT"); + } + + #[test] + fn renders_valid_rss() { + let feed = Feed { + title: "yt-offline β€” Demo & co".into(), + description: "archive".into(), + link: "http://host/".into(), + items: vec![FeedItem { + title: "Episode <1>".into(), + description: "desc".into(), + enclosure_url: "http://host/files/channels/Demo/a.mkv".into(), + enclosure_len: 12345, + enclosure_type: "video/x-matroska".into(), + guid: "vidABC".into(), + pub_date: "Tue, 02 Jan 2024 00:00:00 GMT".into(), + duration: "1:05".into(), + image_url: "http://host/files/channels/Demo/a.webp".into(), + }], + }; + let xml = render(&feed); + assert!(xml.starts_with("yt-offline β€” Demo & co")); + assert!(xml.contains("Episode <1>")); + assert!(xml.contains(r#""#)); + assert!(xml.contains("1:05")); + assert!(xml.contains(r#"vidABC"#)); + assert!(xml.trim_end().ends_with("")); + } +} diff --git a/src/main.rs b/src/main.rs index 74188f2..a7543aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,7 @@ mod disk_space; mod download_options; mod downloader; mod error_class; +mod feed; mod fingerprint; mod library; mod maintenance; diff --git a/src/web.rs b/src/web.rs index b3c1237..6db906a 100644 --- a/src/web.rs +++ b/src/web.rs @@ -138,6 +138,11 @@ pub struct WebState { /// so `/api/maintenance/dedup/status` can be polled. `Arc` so the worker /// thread can hold a handle past the request that started it. pub dedup: std::sync::Arc, + /// Read-only capability token for the podcast/RSS feeds. Podcast clients + /// can't do the cookie login, so a tokenized feed URL (`?token=…`) grants + /// GET access to `/feed*` and the media mounts even when a password is + /// set. Persisted in the `feed_token` setting; stable until regenerated. + pub feed_token: String, } /// Live state for the background perceptual-dedup job (see @@ -1079,6 +1084,16 @@ async fn auth_middleware( if is_authed(&state, req.headers()) { return next.run(req).await; } + // Podcast clients can't perform the cookie login, so a tokenized GET to a + // feed or the media mounts is allowed when the query carries the + // read-only feed token. Scoped to reads of feeds + media only β€” never + // `/api/*` mutations. + if req.method().as_str() == "GET" + && (path.starts_with("/feed") || path.starts_with("/files/") || path.starts_with("/music-files/")) + && req.uri().query().is_some_and(|q| query_has_token(q, &state.feed_token)) + { + return next.run(req).await; + } if path == "/" { return ( [(header::CONTENT_TYPE, "text/html; charset=utf-8")], @@ -1089,6 +1104,15 @@ async fn auth_middleware( (StatusCode::UNAUTHORIZED, "authentication required").into_response() } +/// True if the query string carries `token=` (the feed capability +/// token). Empty `expected` never matches. +fn query_has_token(query: &str, expected: &str) -> bool { + if expected.is_empty() { return false; } + query.split('&').any(|kv| { + kv.strip_prefix("token=").is_some_and(|v| v == expected) + }) +} + // ── Handlers ────────────────────────────────────────────────────────────────── async fn get_index() -> impl IntoResponse { @@ -2013,6 +2037,118 @@ struct RemoveRequest { paths: Vec, } +/// Newest-first cap on items in a feed β€” enough for any podcast client's +/// back-catalogue without rendering a multi-thousand-item document. +const FEED_LIMIT: usize = 300; + +/// Absolute origin (`scheme://host`) of the request, honoring a reverse +/// proxy's `X-Forwarded-Proto`. Feed enclosure URLs must be absolute. +fn request_base_url(headers: &HeaderMap) -> String { + let host = headers.get(header::HOST).and_then(|h| h.to_str().ok()).unwrap_or("localhost"); + let scheme = headers.get("x-forwarded-proto").and_then(|h| h.to_str().ok()) + .map(|s| s.split(',').next().unwrap_or(s).trim()) + .filter(|s| !s.is_empty()) + .unwrap_or("http"); + format!("{scheme}://{host}") +} + +/// Build podcast items from videos (newest first, capped). Each item's media + +/// thumbnail get absolute, token-bearing `/files/` URLs so a podcast client +/// can fetch them even behind a password. +fn build_feed_items( + mut vids: Vec<&library::Video>, root: &StdPath, base: &str, token: &str, limit: usize, +) -> Vec { + vids.retain(|v| v.video_path.is_some()); + vids.sort_by(|a, b| { + b.upload_date.as_deref().unwrap_or("").cmp(a.upload_date.as_deref().unwrap_or("")) + .then(b.mtime_unix.unwrap_or(0).cmp(&a.mtime_unix.unwrap_or(0))) + }); + vids.truncate(limit); + let tok = if token.is_empty() { String::new() } else { format!("?token={token}") }; + vids.into_iter().filter_map(|v| { + let vp = v.video_path.as_ref()?; + let rel = file_url(root, vp)?; + let image_url = v.thumb_path.as_ref() + .and_then(|t| file_url(root, t)) + .map(|r| format!("{base}{r}{tok}")) + .unwrap_or_default(); + // A short description excerpt (best-effort; the media is the point). + let description = v.description_path.as_ref() + .and_then(|p| std::fs::read_to_string(p).ok()) + .map(|s| s.chars().take(800).collect::()) + .unwrap_or_default(); + Some(crate::feed::FeedItem { + title: v.title.clone(), + description, + enclosure_url: format!("{base}{rel}{tok}"), + enclosure_len: v.file_size.unwrap_or(0), + enclosure_type: crate::feed::mime_for(vp).to_string(), + guid: v.id.clone(), + pub_date: crate::feed::pub_date(v.upload_date.as_deref(), v.mtime_unix), + duration: crate::feed::fmt_duration(v.duration_secs), + image_url, + }) + }).collect() +} + +fn feed_response(xml: String) -> Response { + ( + [ + (header::CONTENT_TYPE, "application/rss+xml; charset=utf-8"), + (header::CACHE_CONTROL, "no-cache"), + ], + xml, + ).into_response() +} + +/// `GET /feed.xml` β€” the whole library as one podcast feed, newest first. +async fn get_feed_all(State(state): State>, headers: HeaderMap) -> Response { + let base = request_base_url(&headers); + let lib = state.library.lock_recover(); + let root = state.library_root.as_path(); + let all: Vec<&library::Video> = lib.iter() + .flat_map(|c| c.videos.iter().chain(c.playlists.iter().flat_map(|p| p.videos.iter()))) + .collect(); + let items = build_feed_items(all, root, &base, &state.feed_token, FEED_LIMIT); + let feed = crate::feed::Feed { + title: "yt-offline β€” Library".into(), + description: "Your archived videos as a podcast feed.".into(), + link: format!("{base}/"), + items, + }; + feed_response(crate::feed::render(&feed)) +} + +/// `GET /feed/:platform/:handle` β€” one channel's videos as a podcast feed. +async fn get_feed_channel( + State(state): State>, + headers: HeaderMap, + Path((platform, handle)): Path<(String, String)>, +) -> Response { + let base = request_base_url(&headers); + let lib = state.library.lock_recover(); + let root = state.library_root.as_path(); + let Some(ch) = lib.iter().find(|c| c.platform.dir_name() == platform && c.name == handle) else { + return (StatusCode::NOT_FOUND, "no such channel").into_response(); + }; + let vids: Vec<&library::Video> = ch.videos.iter() + .chain(ch.playlists.iter().flat_map(|p| p.videos.iter())).collect(); + let items = build_feed_items(vids, root, &base, &state.feed_token, FEED_LIMIT); + let feed = crate::feed::Feed { + title: format!("yt-offline β€” {}", ch.name), + description: format!("Archived videos from {}.", ch.name), + link: format!("{base}/"), + items, + }; + feed_response(crate::feed::render(&feed)) +} + +/// `GET /api/feed-info` β€” the read-only feed token, so the (authed) UI can +/// build tokenized feed URLs to copy. +async fn get_feed_info(State(state): State>) -> impl IntoResponse { + Json(serde_json::json!({ "token": state.feed_token })) +} + /// `POST /api/maintenance/dedup/scan` β€” start (or no-op if already running) /// the background perceptual-dedup pass. Fingerprints any new/changed videos /// (cached by mtime), then groups by visual similarity. Returns immediately; @@ -2727,6 +2863,13 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { let password_required_initial = db.get_setting("password_hash").ok().flatten().is_some(); let flags = db.get_video_flags().unwrap_or_default(); + // Stable read-only feed token: load the stored one, or mint + persist a new + // one on first run so the same URL keeps working across restarts. + let feed_token = db.get_setting("feed_token").ok().flatten().unwrap_or_else(|| { + let t = generate_session_token(); + let _ = db.set_setting("feed_token", Some(&t)); + t + }); // Capacity 16 is plenty β€” only a small number of browser tabs ever // subscribe and the broadcast is lossy by design (subscribers that lag // get a Lagged error and just resubscribe). @@ -2751,6 +2894,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { login_attempts: Mutex::new(HashMap::new()), library_body_cache: Mutex::new(None), dedup: std::sync::Arc::new(DedupState::default()), + feed_token, }); // Broadcast progress snapshots to WebSocket subscribers. Ticks fast @@ -2854,6 +2998,9 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { .route("/api/chapters/:id", get(get_chapters)) .route("/api/comments/:id", get(get_comments)) .route("/api/search", get(get_search)) + .route("/api/feed-info", get(get_feed_info)) + .route("/feed.xml", get(get_feed_all)) + .route("/feed/:platform/:handle", get(get_feed_channel)) .route("/api/metadata/:id", get(get_metadata)) .route("/api/settings", get(get_settings).post(post_settings)) .route("/api/transcode/:id", get(get_transcode)) diff --git a/src/web_ui/index.html b/src/web_ui/index.html index 2a09790..ec6290b 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -1041,6 +1041,7 @@ async function openChannelOptions(idx){
+ @@ -1508,6 +1509,24 @@ function renderComments(){ } } +/* ── Podcast feed URLs ──────────────────────────────────────────── */ +function copyFeedUrl(id,btn){ + const el=document.getElementById(id);if(!el||!el.value)return; + el.select(); + const done=()=>{if(btn){const t=btn.textContent;btn.textContent='βœ“ Copied';setTimeout(()=>btn.textContent=t,1200)}}; + if(navigator.clipboard)navigator.clipboard.writeText(el.value).then(done).catch(()=>{document.execCommand('copy');done()}); + else{document.execCommand('copy');done()} +} +async function copyChannelFeed(platform,handle,btn){ + try{ + const {token}=await(await api('/api/feed-info')).json(); + const url=`${location.origin}/feed/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}?token=${token}`; + if(navigator.clipboard)await navigator.clipboard.writeText(url); + if(btn){const t=btn.textContent;btn.textContent='βœ“ Copied';setTimeout(()=>btn.textContent=t,1200)} + setStatus('Channel feed URL copied to clipboard'); + }catch(e){setStatus('Error: '+e.message)} +} + /* ── Full-text library search ───────────────────────────────────── */ let ftsSeq=0; function openSearch(){ @@ -1665,6 +1684,15 @@ async function openSettings(){ }else if(ck.exists&&ck.days_left!=null&&ck.days_left<=3){ cookieWarn=`
⚠ Login cookies expire in ${ck.days_left}d. Refresh soon to avoid captcha errors.
`; } + let feed={token:''};try{feed=await(await api('/api/feed-info')).json()}catch{} + const feedUrl=feed.token?`${location.origin}/feed.xml?token=${feed.token}`:''; + const feedRow=`
+
πŸ“‘ Podcast feed
+
Subscribe to your whole library in any podcast / media app. The token in the URL grants read-only access to the feed and its media β€” so it works even with a password set. Treat it like a share link.
+
+ + +
`; const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()}; bg.innerHTML=` ${bindRows} + ${feedRow}
${cookiesStatus}
diff --git a/tests/api.rs b/tests/api.rs index 0f74767..f0d44db 100644 --- a/tests/api.rs +++ b/tests/api.rs @@ -367,6 +367,67 @@ fn full_text_search_indexes_titles_and_descriptions() { "unrelated query must not hit"); } +#[test] +fn podcast_feed_serves_rss_with_enclosures() { + if !have_curl() { eprintln!("skip: no curl"); return; } + let s = Server::start(); + let chan = s.dir.join("ch/channels/Demo"); + std::fs::create_dir_all(&chan).unwrap(); + std::fs::write(chan.join("Cool Talk [vidXYZ].mp4"), b"fakevideo").unwrap(); + std::fs::write(chan.join("Cool Talk [vidXYZ].info.json"), + br#"{"duration":125.0,"upload_date":"20240102"}"#).unwrap(); + assert_eq!(s.post("/api/rescan", "").0, 200); + + let (code, body) = s.get("/feed.xml"); + assert_eq!(code, 200, "{body}"); + assert!(body.contains("Cool Talk"), "item title present: {body}"); + assert!(body.contains("