0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 17:53:55 +08:00
discourse/spec/lib/onebox/engine/youtube_onebox_spec.rb
Martin Brennan 2b04fbeb30
FIX: Use oEmbed for YouTube oneboxing (#35959)
We currently rely on Open Graph data to render YouTube oneboxes.
However, YouTube does not always give us a response with the Open Graph
data necessary, leading to empty preview HTML like this that shows
as a broken image in the composer:

```
<img src="" width="480" height="360" title=" - YouTube" style="aspect-ratio: 480 / 360;">
```

In addition, our old method of parsing YouTube script tags for JSON
which contains information for the video, introduced in

4d669d2b71,
seems to no longer work reliably, possibly due to changes in YouTube's
JS structure.

To fix this, we switch to using YouTube's oEmbed endpoint which gives us
all the metadata we need for oneboxes in JSON format, including title,
author,
thumbnail URL, and video dimensions. This approach is more robust and
less likely to break
due to changes in YouTube's page structure.

c.f.
https://meta.discourse.org/t/youtube-uris-fail-to-render-thumbnails-when-oneboxed/387673
2025-11-12 14:13:16 +10:00

338 lines
12 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe Onebox::Engine::YoutubeOnebox do
let(:oembed_standard_response) do
{
title: "96neko - orange",
author_name: "96neko",
type: "video",
thumbnail_url: "https://i.ytimg.com/vi/21Lk4YiASMo/hqdefault.jpg",
}.to_json
end
before do
stub_request(
:get,
"https://www.youtube.com/watch?feature=player_embedded&v=21Lk4YiASMo",
).to_return(status: 200, body: onebox_response("youtube"))
stub_request(:get, "https://youtu.be/21Lk4YiASMo").to_return(
status: 200,
body: onebox_response("youtube"),
)
stub_request(:get, "https://www.youtube.com/embed/21Lk4YiASMo").to_return(
status: 200,
body: onebox_response("youtube"),
)
stub_request(:get, "http://www.youtube.com/watch?v=21Lk4YiASMo").to_return(
status: 200,
body: onebox_response("youtube"),
)
stub_request(:get, "https://www.youtube.com/watch?v=21Lk4YiASMo").to_return(
status: 200,
body: onebox_response("youtube"),
)
stub_request(:get, "https://www.youtube.com/live/eJemwqO0SDw").to_return(
status: 200,
body: onebox_response("youtube"),
)
stub_request(:get, "https://www.youtube.com/embed/eJemwqO0SDw").to_return(
status: 200,
body: onebox_response("youtube"),
)
stub_request(:get, "https://www.youtube.com/channel/UCL8ZULXASCc1I_oaOT0NaOQ").to_return(
status: 200,
body: onebox_response("youtube-channel"),
)
stub_request(:get, "http://www.youtube.com/user/googlechrome").to_return(
status: 200,
body: onebox_response("youtube-channel"),
)
stub_request(:get, "https://www.youtube.com/playlist?list=PL5308B2E5749D1696").to_return(
status: 200,
body: onebox_response("youtube-playlist"),
)
stub_request(:get, "https://www.youtube.com/embed/KCyIfcevExE").to_return(
status: 200,
body: onebox_response("youtube-embed"),
)
stub_request(:get, "https://www.youtube.com/embed/VvoFuaLAslw").to_return(
status: 200,
body: onebox_response("youtube-shorts"),
)
stub_request(:get, "https://www.youtube.com/watch?v=KCyIfcevExE").to_return(
status: 200,
body: onebox_response("youtube"),
)
stub_request(:get, "https://youtube.com/shorts/VvoFuaLAslw").to_return(
status: 200,
body: onebox_response("youtube-shorts"),
)
# Stub oEmbed API calls for video URLs
stub_request(
:get,
%r{https://www\.youtube\.com/oembed\?url=https://www\.youtube\.com/watch},
).to_return(status: 200, body: oembed_standard_response)
# Make oEmbed fail for KCyIfcevExE to test fallback to embed page parsing
# Must come after the general stub to override it
stub_request(
:get,
"https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=KCyIfcevExE",
).to_return(status: 404)
stub_request(:get, %r{https://www\.youtube\.com/oembed\?url=https://youtu\.be}).to_return(
status: 200,
body: oembed_standard_response,
)
stub_request(
:get,
%r{https://www\.youtube\.com/oembed\?url=http://www\.youtube\.com/watch},
).to_return(status: 200, body: oembed_standard_response)
stub_request(
:get,
%r{https://www\.youtube\.com/oembed\?url=https://www\.youtube\.com/live},
).to_return(status: 200, body: oembed_standard_response)
stub_request(
:get,
%r{https://www\.youtube\.com/oembed\?url=https://youtube\.com/shorts},
).to_return(
status: 200,
body: {
title: "POMBO",
thumbnail_url: "https://i.ytimg.com/vi/VvoFuaLAslw/hqdefault.jpg",
}.to_json,
)
# Channels, playlists, etc. don't support oEmbed - return 404
stub_request(
:get,
%r{https://www\.youtube\.com/oembed\?url=https://www\.youtube\.com/channel},
).to_return(status: 404)
stub_request(
:get,
%r{https://www\.youtube\.com/oembed\?url=http://www\.youtube\.com/user},
).to_return(status: 404)
stub_request(
:get,
%r{https://www\.youtube\.com/oembed\?url=https://www\.youtube\.com/playlist},
).to_return(status: 404)
end
it "adds wmode=opaque" do
expect(Onebox.preview("https://www.youtube.com/watch?v=21Lk4YiASMo").to_s).to match(
/wmode=opaque/,
)
end
it "rewrites URLs for videos to be HTTPS" do
# match: plain HTTP and protocol agnostic
regex = %r{(http:|["']//)}
expect(Onebox.preview("https://www.youtube.com/watch?v=21Lk4YiASMo").to_s).not_to match(regex)
expect(
Onebox.preview("https://www.youtube.com/watch?v=21Lk4YiASMo").placeholder_html,
).not_to match(regex)
expect(
Onebox.preview("https://www.youtube.com/channel/UCL8ZULXASCc1I_oaOT0NaOQ").to_s,
).not_to match(regex)
end
it "can onebox a channel page" do
expect(
Onebox.preview("https://www.youtube.com/channel/UCL8ZULXASCc1I_oaOT0NaOQ").to_s,
).to match(/Google Chrome/)
end
it "can onebox a playlist" do
expect(
Onebox.preview("https://www.youtube.com/playlist?list=PL5308B2E5749D1696").to_s,
).to match(/iframe/)
placeholder_html =
Onebox.preview("https://www.youtube.com/playlist?list=PL5308B2E5749D1696").placeholder_html
expect(placeholder_html).to match(/<img/)
expect(placeholder_html).to include("The web is what you make of it")
end
it "does not make HTTP requests unless necessary" do
# We haven't defined any fixture for requests associated with this ID, so if
# any HTTP requests are made webmock will complain and the test will fail.
Onebox.preview("http://www.youtube.com/watch?v=q39Ce3zDScI").to_s
end
it "does not fail if we cannot get the video ID from the URL" do
expect(
Onebox.preview("http://www.youtube.com/watch?feature=player_embedded&v=21Lk4YiASMo").to_s,
).to match(/embed/)
end
it "returns an image as the placeholder" do
expect(Onebox.preview("https://www.youtube.com/watch?v=21Lk4YiASMo").placeholder_html).to match(
/<img/,
)
expect(Onebox.preview("https://www.youtube.com/live/eJemwqO0SDw").placeholder_html).to match(
/<img/,
)
expect(Onebox.preview("https://youtu.be/21Lk4YiASMo").placeholder_html).to match(/<img/)
end
it "passes the playlist ID through" do
expect(
Onebox.preview(
"https://www.youtube.com/watch?v=21Lk4YiASMo&list=UUQau-O2C0kGJpR3_CHBTGbw&index=1",
).to_s,
).to match(/UUQau-O2C0kGJpR3_CHBTGbw/)
end
it "filters out nonsense parameters" do
expect(
Onebox.preview(
"https://www.youtube.com/watch?v=21Lk4YiASMo&potential[]=exploit&potential[]=fun",
).to_s,
).not_to match(/potential|exploit|fun/)
end
it "ignores video_id with unacceptable characters" do
# (falls back to generic onebox)
Onebox::Engine::AllowlistedGenericOnebox
.any_instance
.stubs(:to_html)
.returns(+"allowlisted_html")
expect(Onebox.preview("https://www.youtube.com/watch?v=%3C%3E21Lk4YiASMo").to_s).to eq(
"allowlisted_html",
)
end
it "ignores list_id with unacceptable characters" do
# (falls back to video-only onebox)
expect(
Onebox.preview(
"https://www.youtube.com/watch?v=21Lk4YiASMo&list=%3C%3EUUQau-O2C0kGJpR3_CHBTGbw",
).to_s,
).not_to include("UUQau-O2C0kGJpR3_CHBTGbw")
end
it "converts time strings into a &start= parameter" do
expect(Onebox.preview("https://www.youtube.com/watch?v=21Lk4YiASMo&start=3782").to_s).to match(
/start=3782/,
)
expect(
Onebox.preview("https://www.youtube.com/watch?start=1h3m2s&v=21Lk4YiASMo").to_s,
).to match(/start=3782/)
expect(Onebox.preview("https://www.youtube.com/watch?v=21Lk4YiASMo&t=1h3m2s").to_s).to match(
/start=3782/,
)
expect(
Onebox.preview("https://www.youtube.com/watch?v=21Lk4YiASMo&start=1h3m2s").to_s,
).to match(/start=3782/)
expect(Onebox.preview("https://www.youtube.com/watch?v=21Lk4YiASMo#t=1h3m2s").to_s).to match(
/start=3782/,
)
end
it "allows both start and end" do
preview =
expect(Onebox.preview("https://www.youtube.com/watch?v=21Lk4YiASMo&start=2m&end=3m").to_s)
preview.to match(/start=120/)
preview.to match(/end=180/)
end
it "permits looping videos" do
preview = expect(Onebox.preview("https://www.youtube.com/watch?v=21Lk4YiASMo&loop").to_s)
preview.to match(/loop=1/)
preview.to match(/playlist=21Lk4YiASMo/)
end
it "includes title in preview" do
expect(Onebox.preview("https://youtu.be/21Lk4YiASMo").placeholder_html).to include(
"96neko - orange",
)
end
it "can parse youtube embed results" do
preview = expect(Onebox.preview("https://www.youtube.com/watch?v=KCyIfcevExE").placeholder_html)
preview.to match(/Delvon/)
preview.to match(/hqdefault/)
end
it "can parse youtube shorts results" do
preview = expect(Onebox.preview("https://youtube.com/shorts/VvoFuaLAslw").placeholder_html)
preview.to match(/POMBO/)
preview.to match(/hqdefault/)
end
it "can parse youtube live URLs" do
preview = expect(Onebox.preview("https://www.youtube.com/live/eJemwqO0SDw").to_s)
preview.to match(/iframe/)
preview.to include("embed/eJemwqO0SDw")
end
it "generates a thumbnail for videos" do
expect(Onebox.preview("https://www.youtube.com/watch?v=21Lk4YiASMo").to_s).to match("<img")
end
describe "oEmbed support" do
let(:video_url) { "https://www.youtube.com/watch?v=wC10VWDTzmU" }
let(:oembed_url) { "https://www.youtube.com/oembed?url=#{video_url}" }
let(:oembed_response) do
{
title: "Bob Dylan - Gotta Serve Somebody (Official Audio)",
author_name: "BobDylanVEVO",
author_url: "https://www.youtube.com/@BobDylanVEVO",
type: "video",
height: 113,
width: 200,
version: "1.0",
provider_name: "YouTube",
provider_url: "https://www.youtube.com/",
thumbnail_height: 360,
thumbnail_width: 480,
thumbnail_url: "https://i.ytimg.com/vi/wC10VWDTzmU/hqdefault.jpg",
html:
'<iframe width="200" height="113" src="https://www.youtube.com/embed/wC10VWDTzmU?feature=oembed"></iframe>',
}.to_json
end
before do
stub_request(:get, oembed_url).to_return(status: 200, body: oembed_response)
stub_request(:get, video_url).to_return(status: 200, body: onebox_response("youtube"))
end
it "uses oEmbed API for metadata" do
onebox = Onebox::Engine::YoutubeOnebox.new(video_url)
result = onebox.send(:parse_embed_response)
expect(result).to be_present
expect(result[:title]).to eq("Bob Dylan - Gotta Serve Somebody (Official Audio)")
expect(result[:image]).to eq("https://i.ytimg.com/vi/wC10VWDTzmU/hqdefault.jpg")
end
it "includes oEmbed data in placeholder_html" do
placeholder = Onebox.preview(video_url).placeholder_html
expect(placeholder).to include("Bob Dylan - Gotta Serve Somebody (Official Audio)")
expect(placeholder).to include("https://i.ytimg.com/vi/wC10VWDTzmU/hqdefault.jpg")
expect(placeholder).to match(/<img/)
end
it "falls back to OpenGraph when oEmbed fails" do
stub_request(:get, oembed_url).to_return(status: 404)
stub_request(:get, "https://www.youtube.com/embed/wC10VWDTzmU").to_return(
status: 200,
body: onebox_response("youtube-embed"),
)
onebox = Onebox::Engine::YoutubeOnebox.new(video_url)
result = onebox.send(:parse_embed_response)
# Should fall back but won't get data from the broken embed page
# The fallback to get_opengraph happens in placeholder_html
placeholder = onebox.placeholder_html
expect(placeholder).to match(/<img/)
end
end
end