Files
stoatchat/crates/services/january/src/website_embed.rs
Zomatree bfe4018e43 fix: apple music to use original url instead of metadata url
Apple Music returns the url without the query parameter in the opengraph meta tags, causing the regex to not find the track id, meaning any embed links generated would only link to the album.
2025-09-19 02:41:53 +01:00

336 lines
13 KiB
Rust

use std::collections::HashMap;
use lazy_static::lazy_static;
use regex::Regex;
use revolt_models::v0::{
BandcampType, Image, ImageSize, LightspeedType, Special, TwitchType, Video, WebsiteMetadata,
};
use scraper::{Html, Selector};
/// Create website metadata from URL and document
pub async fn create_website_embed(original_url: &str, document: &str) -> Option<WebsiteMetadata> {
let (mut meta, mut link) = {
let document = Html::parse_document(document);
// create selectors
let meta_selector = Selector::parse("meta").ok()?;
let link_selector = Selector::parse("link").ok()?;
// extract meta tags
let mut meta = HashMap::new();
for el in document.select(&meta_selector) {
let node = el.value();
if let (Some(property), Some(content)) = (
node.attr("property").or_else(|| node.attr("name")),
node.attr("content"),
) {
meta.insert(property.to_string(), content.to_string());
}
}
// extract rel links
let mut link = HashMap::new();
for el in document.select(&link_selector) {
let node = el.value();
if let (Some(property), Some(content)) = (node.attr("rel"), node.attr("href")) {
link.insert(property.to_string(), content.to_string());
}
}
(meta, link)
};
// build metadata
let mut metadata = WebsiteMetadata {
title: meta
.remove("og:title")
.or_else(|| meta.remove("twitter:title"))
.or_else(|| meta.remove("title"))
.map(|s| s.trim().to_owned()),
description: meta
.remove("og:description")
.or_else(|| meta.remove("twitter:description"))
.or_else(|| meta.remove("description"))
.map(|s| s.trim().to_owned()),
image: meta
.remove("og:image")
.or_else(|| meta.remove("og:image:secure_url"))
.or_else(|| meta.remove("twitter:image"))
.or_else(|| meta.remove("twitter:image:src"))
.map(|s| s.trim().to_owned())
.map(|mut url| {
// If relative URL, prepend root URL. Also if root URL ends with a slash, remove it.
if let Some(ch) = url.chars().next() {
if ch == '/' {
url = format!("{}{}", &original_url.trim_end_matches('/'), url);
}
}
let mut size = ImageSize::Preview;
if let Some(card) = meta.remove("twitter:card") {
if &card == "summary_large_image" {
size = ImageSize::Large;
}
}
Image {
url: url.to_owned(),
width: meta
.remove("og:image:width")
.unwrap_or_default()
.parse()
.unwrap_or(0),
height: meta
.remove("og:image:height")
.unwrap_or_default()
.parse()
.unwrap_or(0),
size,
}
}),
video: meta
.remove("og:video")
.or_else(|| meta.remove("og:video:url"))
.or_else(|| meta.remove("og:video:secure_url"))
.map(|s| s.trim().to_owned())
.map(|mut url| {
// If relative URL, prepend root URL. Also if root URL ends with a slash, remove it.
if let Some(ch) = url.chars().next() {
if ch == '/' {
url = format!("{}{}", &original_url.trim_end_matches('/'), url);
}
}
Video {
url: url.to_owned(),
width: meta
.remove("og:video:width")
.unwrap_or_default()
.parse()
.unwrap_or(0),
height: meta
.remove("og:video:height")
.unwrap_or_default()
.parse()
.unwrap_or(0),
}
}),
icon_url: link
.remove("apple-touch-icon")
.or_else(|| link.remove("icon"))
.map(|s| s.trim().to_owned())
.map(|mut v| {
// If relative URL, prepend root URL.
if let Some(ch) = v.chars().next() {
if ch == '/' {
v = format!("{}{}", &original_url.trim_end_matches('/'), v);
}
}
v
}),
colour: meta.remove("theme-color").map(|s| s.trim().to_owned()),
site_name: meta.remove("og:site_name").map(|s| s.trim().to_owned()),
url: meta
.remove("og:url")
.or_else(|| Some(original_url.to_owned())),
original_url: Some(original_url.to_owned()),
special: None,
};
// populate extra metadata for popular websites
populate_special(original_url.to_owned(), &mut metadata).await;
// fetch video size if missing
if metadata.special.is_none() {
if let Some(Video { width, height, url }) = &metadata.video {
if width == &0 || height == &0 {
metadata.video =
match crate::requests::Request::fetch_video_metadata(url, None).await {
Ok(Some(video)) => Some(video),
_ => None,
}
}
}
}
// remove image if video exists
if metadata.video.is_some() {
metadata.image.take();
}
// fetch image size if missing
if metadata.special.is_none() {
if let Some(Image {
width, height, url, ..
}) = &metadata.image
{
if width == &0 || height == &0 {
metadata.image =
match crate::requests::Request::fetch_image_metadata(url, None).await {
Ok(Some(image)) => Some(image),
_ => None,
}
}
}
}
// truncate data
metadata.truncate();
// if it's empty, don't return anything
if metadata.is_empty() {
None
} else {
Some(metadata)
}
}
pub async fn populate_special(original_url: String, metadata: &mut WebsiteMetadata) {
lazy_static! {
static ref RE_YOUTUBE: Regex = Regex::new("^(?:(?:https?:)?//)?(?:(?:www|m)\\.)?(?:(?:youtube\\.com|youtu.be))(?:/(?:[\\w\\-]+\\?v=|embed/|v/)?)([\\w\\-]+)(?:\\S+)?$").unwrap();
static ref RE_LIGHTSPEED: Regex = Regex::new("^(?:https?://)?(?:[\\w]+\\.)?lightspeed\\.tv/([a-z0-9_]{4,25})").unwrap();
static ref RE_TWITCH: Regex = Regex::new("^(?:https?://)?(?:www\\.|go\\.)?twitch\\.tv/([a-z0-9_]+)($|\\?)").unwrap();
static ref RE_TWITCH_VOD: Regex = Regex::new("^(?:https?://)?(?:www\\.|go\\.)?twitch\\.tv/videos/([0-9]+)($|\\?)").unwrap();
static ref RE_TWITCH_CLIP: Regex = Regex::new("^(?:https?://)?(?:www\\.|go\\.)?twitch\\.tv/(?:[a-z0-9_]+)/clip/([A-z0-9_-]+)($|\\?)").unwrap();
static ref RE_SPOTIFY: Regex = Regex::new("^(?:https?://)?open.spotify.com/(track|user|artist|album|playlist)/([A-z0-9]+)").unwrap();
static ref RE_SOUNDCLOUD: Regex = Regex::new("^(?:https?://)?soundcloud.com/([a-zA-Z0-9-]+)/([A-z0-9-]+)").unwrap();
static ref RE_BANDCAMP: Regex = Regex::new("^(?:https?://)?(?:[A-z0-9_-]+).bandcamp.com/(track|album)/([A-z0-9_-]+)").unwrap();
static ref RE_APPLE_MUSIC: Regex = Regex::new("^(?:https?://)?music\\.apple\\.com/(?:[a-z]{2}/)?album/(?:[a-zA-Z0-9-]+)/(\\d+)(?:\\?i=(\\d+))?").unwrap();
static ref RE_STREAMABLE: Regex = Regex::new("^(?:https?://)?(?:www\\.)?streamable\\.com/([\\w\\d-]+)").unwrap();
static ref RE_GIF: Regex = Regex::new("^(?:https?://)?(www\\.)?(gifbox\\.me/view|yiffbox\\.me/view|tenor\\.com/view|giphy\\.com/gifs|gfycat\\.com|redgifs\\.com/watch)/[\\w\\d-]+").unwrap();
}
let url = metadata
.url
.as_ref()
.or(metadata.original_url.as_ref())
.unwrap_or(&original_url)
.as_str();
metadata.special = if let Some(captures) = RE_STREAMABLE.captures_iter(url).next() {
Some(Special::Streamable {
id: captures[1].to_string(),
})
} else if let Some(captures) = RE_YOUTUBE.captures_iter(url).next() {
let id = captures[1].to_string();
lazy_static! {
static ref RE_TIMESTAMP: Regex = Regex::new("(?:\\?|&)(?:t|start)=([\\w]+)").unwrap();
}
// YouTube now blocks datacentre IPs from fetching information
// This is a fallback to prevent the embed from looking weird
if metadata.video.is_none() {
metadata.title.replace("YouTube".to_owned());
metadata.description.take();
metadata.colour.take();
metadata.icon_url.take();
metadata.site_name.take();
// Verify the video exists
if !crate::requests::Request::exists(&format!(
"http://img.youtube.com/vi/{}/sddefault.jpg",
id
))
.await
{
return;
}
}
if let Some(timestamp_captures) = RE_TIMESTAMP.captures_iter(url).next() {
Some(Special::YouTube {
id,
timestamp: Some(timestamp_captures[1].to_string()),
})
} else {
Some(Special::YouTube {
id,
timestamp: None,
})
}
} else if let Some(captures) = RE_LIGHTSPEED.captures_iter(url).next() {
Some(Special::Lightspeed {
id: captures[1].to_string(),
content_type: LightspeedType::Channel,
})
} else if let Some(captures) = RE_TWITCH.captures_iter(url).next() {
Some(Special::Twitch {
id: captures[1].to_string(),
content_type: TwitchType::Channel,
})
} else if let Some(captures) = RE_TWITCH_VOD.captures_iter(url).next() {
Some(Special::Twitch {
id: captures[1].to_string(),
content_type: TwitchType::Video,
})
} else if let Some(captures) = RE_TWITCH_CLIP.captures_iter(url).next() {
Some(Special::Twitch {
id: captures[1].to_string(),
content_type: TwitchType::Clip,
})
} else if let Some(captures) = RE_SPOTIFY.captures_iter(url).next() {
Some(Special::Spotify {
content_type: captures[1].to_string(),
id: captures[2].to_string(),
})
} else if RE_SOUNDCLOUD.is_match(url) {
Some(Special::Soundcloud)
} else if RE_BANDCAMP.is_match(url) {
lazy_static! {
static ref RE_TRACK: Regex = Regex::new("track=(\\d+)").unwrap();
static ref RE_ALBUM: Regex = Regex::new("album=(\\d+)").unwrap();
}
if let Some(video) = &metadata.video {
if let Some(captures) = RE_TRACK.captures_iter(&video.url).next() {
Some(Special::Bandcamp {
content_type: BandcampType::Track,
id: captures[1].to_string(),
})
} else {
RE_ALBUM
.captures_iter(&video.url)
.next()
.map(|captures| Special::Bandcamp {
content_type: BandcampType::Album,
id: captures[1].to_string(),
})
}
} else {
None
}
} else if RE_GIF.is_match(url) {
Some(Special::GIF)
} else {
RE_APPLE_MUSIC
.captures_iter(&original_url)
.next()
.map(|captures| Special::AppleMusic {
album_id: captures[1].to_string(),
track_id: captures.get(2).map(|m| m.as_str().to_string()),
})
};
// add colours for popular websites
if let Some(special) = &metadata.special {
match special {
Special::YouTube { .. } => metadata.colour = Some("#FF424F".to_string()),
Special::Twitch { .. } => metadata.colour = Some("#7B68EE".to_string()),
Special::Lightspeed { .. } => metadata.colour = Some("#7445D9".to_string()),
Special::Spotify { .. } => metadata.colour = Some("#1ABC9C".to_string()),
Special::Soundcloud => metadata.colour = Some("#FF7F50".to_string()),
Special::AppleMusic { .. } => metadata.colour = Some("#FA233B".to_string()),
_ => {}
}
}
}