Describe YouTube links on every service: title, channel, provider (#237)

Discord describes a posted video itself; nothing else does, so an IRC,
Sneedchat, Kick or encrypted Matrix message with a YouTube link showed a
bare thumbnail. unfurl.rs asks YouTube's oEmbed endpoint (no key) for the
first YouTube link in each new message and stores the answer as an
ordinary embed. It goes the account's way: through Tor or the proxy for a
routed account, and with no route ready the card is not made rather than
fetched directly. Two at a time, remembered per video.

Embed gains provider and author, which Discord's own embeds now keep too.
set_message_embeds puts cards on a message without marking it edited;
Matrix's homeserver previews use it as well (update_message marked them
"(edited)" and cleared the message's attachments), and leave YouTube links
to unfurl.rs so the two do not race.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-03 00:41:20 -04:00
co-authored by Claude Opus 5.5
parent a3897bab95
commit e78ae8dc3e
7 changed files with 248 additions and 4 deletions
+2
View File
@@ -716,6 +716,8 @@ pub(super) fn extract_embeds(d: &Value) -> Vec<Embed> {
// message's media sniffing, which pairs them with the embed
// in the frontend. Nothing to fetch here.
image_url: None,
provider: embed["provider"]["name"].as_str().filter(|s| !s.is_empty()).map(str::to_string),
author: embed["author"]["name"].as_str().filter(|s| !s.is_empty()).map(str::to_string),
})
})
.collect()
+11 -3
View File
@@ -116,6 +116,7 @@ pub async fn fetch(state: &AppState, account_id: &str, url: &str, ts_ms: i64) ->
timestamp: None,
url: Some(url.to_string()),
image_url: image,
..Default::default()
})
}
@@ -137,6 +138,12 @@ pub(super) fn unfurl_later(
return;
}
let Some(link) = first_link(body) else { return };
// A YouTube link is described by YouTube itself, with its channel, for
// every service alike (unfurl.rs) - two cards racing to be the one on the
// message would only flicker.
if crate::unfurl::youtube_id(link).is_some() {
return;
}
let (state, account_id, buffer_id, event_id, body, link) = (
state.clone(),
account_id.to_string(),
@@ -147,9 +154,10 @@ pub(super) fn unfurl_later(
);
tokio::spawn(async move {
let Some(embed) = fetch(&state, &account_id, &link, ts_ms).await else { return };
// The body again, unchanged: update_message takes the whole message
// and this only means to add the card to it.
state.runtime.update_message(&state, &buffer_id, &event_id, &body, &[embed], &[]);
// Not an edit: nobody changed the message, and its attachments are
// its own business.
let _ = body;
state.runtime.set_message_embeds(&state, &buffer_id, &event_id, &[embed]);
});
}
+1
View File
@@ -18,6 +18,7 @@ mod runtime;
mod secure;
mod state;
mod store;
mod unfurl;
mod upload;
mod voicenote;
+9 -1
View File
@@ -467,7 +467,7 @@ pub struct Reaction {
/// Discord's own client does, and re-renders it live if a later edit
/// changes it (a webhook can restyle an existing embed - status-bridge
/// bots commonly do this to signal connected/degraded/down).
#[derive(Serialize, Deserialize, Clone, Debug)]
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct Embed {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
@@ -489,6 +489,14 @@ pub struct Embed {
/// unfurled link is, whoever unfurled it.
#[serde(rename = "imageUrl", skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
/// Who serves what the card is about - "YouTube" - drawn small above the
/// rest, as Discord does. Discord's embeds carry it; an unfurled link
/// gets it from whoever described it (see unfurl.rs).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// Who made it: a video's channel, an article's byline.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
}
/// Something on a message that can be pressed.
+23
View File
@@ -4226,6 +4226,13 @@ impl Runtime {
};
state.events.emit("message", serde_json::to_value(&message).unwrap());
// A YouTube link with nothing describing it - every service but
// Discord, which describes its own. After the line is out, never
// holding it up.
if message.embeds.is_empty() && kind != "system" {
crate::unfurl::youtube_later(state, account_id, &message.buffer_id, &message.id, &message.body);
}
// Notify on every inbound DM regardless of content, or on a
// highlighted channel message - two distinct rules (see
// daemon/nobilis/uiops_conv.c's should_notify/is_highlight split).
@@ -4267,6 +4274,22 @@ impl Runtime {
}
}
/// A message's cards, arriving after the message did - a link unfurled.
/// Not an edit, in storage or in the event.
pub fn set_message_embeds(&self, state: &AppState, buffer_id: &str, msg_id: &str, embeds: &[Embed]) -> bool {
match state.store.set_message_embeds(buffer_id, msg_id, embeds) {
Ok(true) => {
state.events.emit("messageUpdated", json!({ "bufferId": buffer_id, "id": msg_id, "edited": false, "embeds": embeds }));
true
}
Ok(false) => false,
Err(e) => {
tracing::warn!("failed to put a card on a message: {e}");
false
}
}
}
/// Like update_message, but for a backend-internal body rewrite that
/// isn't a real user edit (see backend/sneedchat's attachment-link
/// resolution) - doesn't set the `edited` flag either in storage or in
+12
View File
@@ -489,6 +489,18 @@ impl Store {
Ok(changed > 0)
}
/// Puts the cards on a message without marking it edited: a link being
/// described after the fact is not something its sender did.
pub fn set_message_embeds(&self, buffer_id: &str, msg_id: &str, embeds: &[Embed]) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let json = serde_json::to_string(embeds)?;
let rows = conn.execute(
"UPDATE messages SET embeds = ?1 WHERE buffer_id = ?2 AND msg_id = ?3",
params![json, buffer_id, msg_id],
)?;
Ok(rows > 0)
}
pub fn update_message_body_silent(&self, buffer_id: &str, msg_id: &str, body: &str) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let rows = conn.execute("UPDATE messages SET body = ?1 WHERE buffer_id = ?2 AND msg_id = ?3", params![body, buffer_id, msg_id])?;
+190
View File
@@ -0,0 +1,190 @@
//! What a YouTube link in a message is: its title and its channel.
//!
//! Discord describes a posted video itself, and its embed arrives with the
//! message. Nothing else does - an IRC line, a Sneedchat post, a Kick chat
//! message, a Matrix event in an encrypted room are a bare URL - so the
//! frontend had a thumbnail and nothing to say what it was a thumbnail of.
//! YouTube answers that for anyone, with no key, through oEmbed: one small
//! JSON request per video.
//!
//! Here rather than in a window because every frontend would otherwise make
//! the same request, and because it is the account's request: it goes the
//! account's way. A channel on Tor has its videos described through Tor, and
//! with no route ready the card is simply not made - never fetched directly
//! instead.
//!
//! Stored on the message as an ordinary embed, so scrollback keeps it and a
//! window opened tomorrow does not ask again.
use crate::model::Embed;
use crate::state::AppState;
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use std::time::Duration;
use tokio::sync::Semaphore;
/// Where each form of link keeps the video id.
const MARKERS: [&str; 6] = [
"youtube.com/watch?v=",
"youtube.com/shorts/",
"youtube.com/embed/",
"youtube.com/live/",
"youtube.com/v/",
"youtu.be/",
];
/// The video id from the first YouTube link in `text`, in any form a link
/// arrives in. The same forms the frontend recognises (lib/format.ts), so a
/// card is made for exactly the links that are drawn as videos.
pub fn youtube_id(text: &str) -> Option<String> {
let mut best: Option<(usize, String)> = None;
for marker in MARKERS {
let mut from = 0;
while let Some(at) = text[from..].find(marker) {
let start = from + at + marker.len();
let id: String = text[start..]
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.take(11)
.collect();
if id.len() == 11 {
if best.as_ref().is_none_or(|(pos, _)| from + at < *pos) {
best = Some((from + at, id));
}
break;
}
from = start;
}
}
best.map(|(_, id)| id)
}
/// Videos already described, and ones YouTube would not describe (private,
/// removed). Bounded crudely - a full table is emptied - since all it saves
/// is a repeat request.
static KNOWN: LazyLock<Mutex<HashMap<String, Option<Embed>>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
const KNOWN_LIMIT: usize = 512;
/// A backlog full of links is fetched two at a time, not all at once.
static SLOTS: Semaphore = Semaphore::const_new(2);
const TIMEOUT: Duration = Duration::from_secs(15);
/// Describes the message's YouTube link, if it has one, and puts the card on
/// it. Spawned: the message is already out.
pub fn youtube_later(state: &AppState, account_id: &str, buffer_id: &str, msg_id: &str, body: &str) {
let Some(id) = youtube_id(body) else { return };
let (state, account_id, buffer_id, msg_id) =
(state.clone(), account_id.to_string(), buffer_id.to_string(), msg_id.to_string());
tokio::spawn(async move {
let Some(embed) = describe(&state, &account_id, &id).await else { return };
state.runtime.set_message_embeds(&state, &buffer_id, &msg_id, &[embed]);
});
}
async fn describe(state: &AppState, account_id: &str, id: &str) -> Option<Embed> {
if let Some(known) = KNOWN.lock().unwrap().get(id) {
return known.clone();
}
let _slot = SLOTS.acquire().await.ok()?;
// Asked again: the request before this one in the queue may have been
// for the same video.
if let Some(known) = KNOWN.lock().unwrap().get(id) {
return known.clone();
}
let router = crate::net::route::router();
let routed = router.tunnel_all()
|| state.accounts.route_level_of(account_id).is_some_and(|level| level != "clearnet");
if routed && router.ready(|_| {}).await.is_err() {
// No route. Not remembered: the next message may find one.
return None;
}
let client = router.client_if("unfurl", routed, |builder| builder.timeout(TIMEOUT));
let watch = format!("https://www.youtube.com/watch?v={id}");
let asked = format!(
"https://www.youtube.com/oembed?format=json&url={}",
url::form_urlencoded::byte_serialize(watch.as_bytes()).collect::<String>()
);
let response = client.get(&asked).send().await.ok()?;
let status = response.status();
let found = if status.is_success() {
let answer: serde_json::Value = response.json().await.ok()?;
parse(&answer, &watch)
} else if status.is_client_error() {
// Private, removed, or not embeddable: YouTube's answer, and it will
// be the same next time.
None
} else {
// Its trouble rather than the video's. Try again another time.
return None;
};
let mut known = KNOWN.lock().unwrap();
if known.len() >= KNOWN_LIMIT {
known.clear();
}
known.insert(id.to_string(), found.clone());
found
}
/// The card, from YouTube's answer. None without a title: a card that says
/// nothing is worse than the thumbnail on its own.
fn parse(answer: &serde_json::Value, watch: &str) -> Option<Embed> {
let text = |key: &str| answer[key].as_str().map(str::trim).filter(|v| !v.is_empty()).map(str::to_string);
Some(Embed {
title: Some(text("title")?),
url: Some(watch.to_string()),
provider: Some(text("provider_name").unwrap_or_else(|| "YouTube".to_string())),
author: text("author_name"),
// YouTube's own red, the bar down the card's edge.
color: Some(0xFF0000),
..Default::default()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_the_video_in_every_form_a_link_arrives_in() {
for url in [
"https://youtu.be/dQw4w9WgXcQ",
"https://youtu.be/dQw4w9WgXcQ?si=abc",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=4",
"https://m.youtube.com/watch?v=dQw4w9WgXcQ",
"https://www.youtube.com/shorts/dQw4w9WgXcQ",
"https://www.youtube.com/embed/dQw4w9WgXcQ",
"https://www.youtube.com/live/dQw4w9WgXcQ",
] {
assert_eq!(youtube_id(url).as_deref(), Some("dQw4w9WgXcQ"), "{url}");
}
assert_eq!(youtube_id("https://example.com/watch?v=dQw4w9WgXcQ"), None);
assert_eq!(youtube_id("https://youtu.be/short"), None);
assert_eq!(youtube_id("no links here"), None);
}
#[test]
fn the_first_link_is_the_one_described() {
let body = "first https://www.youtube.com/watch?v=aaaaaaaaaaa then https://youtu.be/bbbbbbbbbbb";
assert_eq!(youtube_id(body).as_deref(), Some("aaaaaaaaaaa"));
let body = "first https://youtu.be/bbbbbbbbbbb then https://www.youtube.com/watch?v=aaaaaaaaaaa";
assert_eq!(youtube_id(body).as_deref(), Some("bbbbbbbbbbb"));
}
#[test]
fn a_card_needs_a_title() {
let watch = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
let card = parse(
&serde_json::json!({ "title": "Never Gonna Give You Up", "author_name": "Rick Astley", "provider_name": "YouTube" }),
watch,
)
.unwrap();
assert_eq!(card.title.as_deref(), Some("Never Gonna Give You Up"));
assert_eq!(card.author.as_deref(), Some("Rick Astley"));
assert_eq!(card.provider.as_deref(), Some("YouTube"));
assert_eq!(card.url.as_deref(), Some(watch));
assert!(parse(&serde_json::json!({ "title": " ", "author_name": "x" }), watch).is_none());
}
}