From 3095bbfbec5e9420f72891eece613bd9dde9f105 Mon Sep 17 00:00:00 2001 From: Michael Black Date: Wed, 10 Jun 2026 13:54:24 -0500 Subject: [PATCH] bskyweb: add video objects to jsonld SEO (#10814) --- bskyweb/cmd/bskyweb/embedmeta.go | 19 +- bskyweb/cmd/bskyweb/jsonld.go | 114 +++++++++- bskyweb/cmd/bskyweb/jsonld_test.go | 336 +++++++++++++++++++++++++++++ 3 files changed, 450 insertions(+), 19 deletions(-) diff --git a/bskyweb/cmd/bskyweb/embedmeta.go b/bskyweb/cmd/bskyweb/embedmeta.go index 84b8f8a4da..03da46273e 100644 --- a/bskyweb/cmd/bskyweb/embedmeta.go +++ b/bskyweb/cmd/bskyweb/embedmeta.go @@ -4,10 +4,9 @@ import ( appbsky "github.com/bluesky-social/indigo/api/bsky" ) -// Helpers for extracting Open Graph metadata from post embeds. These feed -// og:* / twitter:* meta tags only; the schema.org JSON-LD output lives in -// jsonld.go. og:video has its own path because JSON-LD does not yet emit -// VideoObject (deferred — needs `duration` from the appview). +// Helpers for extracting Open Graph metadata from post embeds. og:video +// shares findVideoEmbed with the JSON-LD VideoObject path in jsonld.go so +// the two outputs cannot drift. // videoMeta holds og:video meta tag data. type videoMeta struct { @@ -21,17 +20,7 @@ type videoMeta struct { // extractVideoMeta returns og:video metadata, or the zero value if there's // no video embed. Respects embedHidden so og:video and og:image stay in sync. func extractVideoMeta(pv *appbsky.FeedDefs_PostView, embedHidden bool) videoMeta { - if pv == nil || pv.Embed == nil || embedHidden { - return videoMeta{} - } - var v *appbsky.EmbedVideo_View - if pv.Embed.EmbedVideo_View != nil { - v = pv.Embed.EmbedVideo_View - } else if pv.Embed.EmbedRecordWithMedia_View != nil && - pv.Embed.EmbedRecordWithMedia_View.Media != nil && - pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil { - v = pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View - } + v := findVideoEmbed(pv, embedHidden) if v == nil || v.Playlist == "" { return videoMeta{} } diff --git a/bskyweb/cmd/bskyweb/jsonld.go b/bskyweb/cmd/bskyweb/jsonld.go index 8d06b71262..4178250493 100644 --- a/bskyweb/cmd/bskyweb/jsonld.go +++ b/bskyweb/cmd/bskyweb/jsonld.go @@ -59,6 +59,7 @@ type discussionForumPosting struct { Text string `json:"text,omitempty"` Image []string `json:"image,omitempty"` ThumbnailURL string `json:"thumbnailUrl,omitempty"` + Video *videoObject `json:"video,omitempty"` DatePublished string `json:"datePublished,omitempty"` InteractionStat []interactionStat `json:"interactionStatistic,omitempty"` CommentCount *int64 `json:"commentCount,omitempty"` @@ -67,6 +68,20 @@ type discussionForumPosting struct { SharedContent *sharedContent `json:"sharedContent,omitempty"` } +// videoObject is the schema.org VideoObject shape for video embeds. +// duration is omitted because the appview does not expose it. +type videoObject struct { + Type string `json:"@type"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + ThumbnailURL string `json:"thumbnailUrl,omitempty"` + UploadDate string `json:"uploadDate,omitempty"` + ContentURL string `json:"contentUrl,omitempty"` + EmbedURL string `json:"embedUrl,omitempty"` + Width int64 `json:"width,omitempty"` + Height int64 `json:"height,omitempty"` +} + // comment is the schema.org Comment shape used in // DiscussionForumPosting.comment[]. The comment property does not accept // DiscussionForumPosting, so replies map to Comment. @@ -78,6 +93,7 @@ type comment struct { Text string `json:"text,omitempty"` Image []string `json:"image,omitempty"` ThumbnailURL string `json:"thumbnailUrl,omitempty"` + Video *videoObject `json:"video,omitempty"` DatePublished string `json:"datePublished,omitempty"` } @@ -127,6 +143,31 @@ func bskyPostURLFromATURI(handle, atURI string) string { return bskyPostURL(handle, parsed.RecordKey().String()) } +// bskyPostURLFromATURIWithDIDFallback returns the handle-form post URL +// when the handle is usable, otherwise falls back to the DID-form URL +// derived from the AT-URI's authority. Returns "" only when the AT-URI is +// unparseable or has no record key. Used for nested fields (e.g. +// VideoObject.embedUrl) where omitting on handle.invalid would weaken the +// emitted structured data. +func bskyPostURLFromATURIWithDIDFallback(handle, atURI string) string { + parsed, err := syntax.ParseATURI(atURI) + if err != nil { + return "" + } + rkey := parsed.RecordKey().String() + if rkey == "" { + return "" + } + if url := bskyPostURL(handle, rkey); url != "" { + return url + } + did := parsed.Authority().String() + if did == "" { + return "" + } + return fmt.Sprintf("https://bsky.app/profile/%s/post/%s", did, rkey) +} + // bskyProfileURL returns the canonical handle-form profile URL, or "" if // the handle is unusable. func bskyProfileURL(handle string) string { @@ -205,6 +246,64 @@ func galleryThumbs(items []*appbsky.EmbedGallery_View_Items_Elem) []string { return urls } +// findVideoEmbed returns the post's video embed view, or nil if there is +// none or embeds are hidden. Shared with extractVideoMeta so og:video and +// JSON-LD VideoObject stay in sync. +func findVideoEmbed(pv *appbsky.FeedDefs_PostView, embedHidden bool) *appbsky.EmbedVideo_View { + if pv == nil || pv.Embed == nil || embedHidden { + return nil + } + if pv.Embed.EmbedVideo_View != nil { + return pv.Embed.EmbedVideo_View + } + if pv.Embed.EmbedRecordWithMedia_View != nil && + pv.Embed.EmbedRecordWithMedia_View.Media != nil && + pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View != nil { + return pv.Embed.EmbedRecordWithMedia_View.Media.EmbedVideo_View + } + return nil +} + +// buildVideoObject returns a VideoObject for the post's video embed, or +// nil if there's no usable video. Falls back to "Video by @" when +// alt text is empty so name is always populated (Google requires it). +// description falls back to name for the same reason. +func buildVideoObject(pv *appbsky.FeedDefs_PostView, embedURL, postText string, embedHidden bool) *videoObject { + v := findVideoEmbed(pv, embedHidden) + if v == nil || v.Playlist == "" { + return nil + } + vo := &videoObject{ + Type: "VideoObject", + ContentURL: v.Playlist, + EmbedURL: embedURL, + // uploadDate uses IndexedAt (not record CreatedAt) for consistency + // with DiscussionForumPosting.datePublished on the parent post. + UploadDate: pv.IndexedAt, + } + if v.Thumbnail != nil { + vo.ThumbnailURL = *v.Thumbnail + } + switch { + case v.Alt != nil && *v.Alt != "": + vo.Name = *v.Alt + case pv.Author != nil && pv.Author.Handle != "" && pv.Author.Handle != "handle.invalid": + vo.Name = "Video by @" + pv.Author.Handle + default: + vo.Name = "Video on Bluesky" + } + if postText != "" { + vo.Description = postText + } else { + vo.Description = vo.Name + } + if v.AspectRatio != nil { + vo.Width = v.AspectRatio.Width + vo.Height = v.AspectRatio.Height + } + return vo +} + // extractQuotedPostURL returns the canonical URL of a quoted post, or "" // if the embed is blocked / not-found / detached / a non-post record. func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string { @@ -394,14 +493,18 @@ func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_Th thumb = images[0] } + postURL := bskyPostURLFromATURIWithDIDFallback(pv.Author.Handle, pv.Uri) + postText := postRecordText(pv) + node := discussionForumPosting{ Type: "DiscussionForumPosting", - URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri), + URL: postURL, Identifier: pv.Uri, Author: buildAuthor(pv.Author), - Text: postRecordText(pv), + Text: postText, Image: images, ThumbnailURL: thumb, + Video: buildVideoObject(pv, postURL, postText, embedHidden), DatePublished: pv.IndexedAt, InteractionStat: buildPostStats(pv), } @@ -464,14 +567,17 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c if len(images) > 0 { thumb = images[0] } + postURL := bskyPostURLFromATURIWithDIDFallback(pv.Author.Handle, pv.Uri) + postText := postRecordText(pv) return comment{ Type: "Comment", - URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri), + URL: postURL, Identifier: pv.Uri, Author: buildAuthor(pv.Author), - Text: postRecordText(pv), + Text: postText, Image: images, ThumbnailURL: thumb, + Video: buildVideoObject(pv, postURL, postText, embedHidden), DatePublished: pv.IndexedAt, } } diff --git a/bskyweb/cmd/bskyweb/jsonld_test.go b/bskyweb/cmd/bskyweb/jsonld_test.go index 5c7f1c2bb7..ee44008030 100644 --- a/bskyweb/cmd/bskyweb/jsonld_test.go +++ b/bskyweb/cmd/bskyweb/jsonld_test.go @@ -1204,3 +1204,339 @@ func TestBuildProfileJSONLD_HasPartAuthorReviewedBy(t *testing.T) { t.Errorf("verifier identifier wrong: %v", rb[0]) } } + +// videoEmbedOpts configures withVideoFull. Zero values mean "not set". +type videoEmbedOpts struct { + thumbnail string + playlist string + alt string + width int64 + height int64 + hasAspect bool + recordMedia bool // nest under EmbedRecordWithMedia_View.Media +} + +// withVideoFull installs a fully-specified video embed for VideoObject tests. +func withVideoFull(o videoEmbedOpts) func(*appbsky.FeedDefs_PostView) { + return func(pv *appbsky.FeedDefs_PostView) { + v := &appbsky.EmbedVideo_View{Playlist: o.playlist} + if o.thumbnail != "" { + v.Thumbnail = strPtr(o.thumbnail) + } + if o.alt != "" { + v.Alt = strPtr(o.alt) + } + if o.hasAspect { + v.AspectRatio = &appbsky.EmbedDefs_AspectRatio{Width: o.width, Height: o.height} + } + if o.recordMedia { + pv.Embed = &appbsky.FeedDefs_PostView_Embed{ + EmbedRecordWithMedia_View: &appbsky.EmbedRecordWithMedia_View{ + Record: &appbsky.EmbedRecord_View{ + Record: &appbsky.EmbedRecord_View_Record{ + EmbedRecord_ViewRecord: &appbsky.EmbedRecord_ViewRecord{ + Uri: "at://did:plc:quoted/app.bsky.feed.post/q", + Cid: "bafy-quoted", + Author: &appbsky.ActorDefs_ProfileViewBasic{ + Did: "did:plc:quoted", + Handle: "quoted.bsky.social", + }, + IndexedAt: "2024-01-01T00:00:00Z", + }, + }, + }, + Media: &appbsky.EmbedRecordWithMedia_View_Media{ + EmbedVideo_View: v, + }, + }, + } + } else { + pv.Embed = &appbsky.FeedDefs_PostView_Embed{ + EmbedVideo_View: v, + } + } + } +} + +func TestBuildPostJSONLD_WithVideoObject(t *testing.T) { + thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg" + playlist := "https://video.bsky.app/v/did:plc:alice/v/playlist.m3u8" + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch this", + withVideoFull(videoEmbedOpts{ + thumbnail: thumb, playlist: playlist, alt: "A trip to the park", + hasAspect: true, width: 16, height: 9, + })) + canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123" + out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels) + if err != nil { + t.Fatal(err) + } + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + video, ok := main["video"].(map[string]any) + if !ok { + t.Fatalf("video missing from mainEntity") + } + if video["@type"] != "VideoObject" { + t.Errorf("@type = %v, want VideoObject", video["@type"]) + } + if video["name"] != "A trip to the park" { + t.Errorf("name = %v, want alt text", video["name"]) + } + if video["description"] != "watch this" { + t.Errorf("description = %v, want post text", video["description"]) + } + if video["thumbnailUrl"] != thumb { + t.Errorf("thumbnailUrl = %v, want %v", video["thumbnailUrl"], thumb) + } + if video["uploadDate"] != pv.IndexedAt { + t.Errorf("uploadDate = %v, want %v", video["uploadDate"], pv.IndexedAt) + } + if video["contentUrl"] != playlist { + t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist) + } + if video["embedUrl"] != canonical { + t.Errorf("embedUrl = %v, want %v", video["embedUrl"], canonical) + } + if w, _ := video["width"].(float64); int64(w) != 16 { + t.Errorf("width = %v, want 16", video["width"]) + } + if h, _ := video["height"].(float64); int64(h) != 9 { + t.Errorf("height = %v, want 9", video["height"]) + } + // post thumbnailUrl should equal the video thumb (byte-equal og:image). + if main["thumbnailUrl"] != thumb { + t.Errorf("post thumbnailUrl = %v, want %v", main["thumbnailUrl"], thumb) + } +} + +func TestBuildPostJSONLD_VideoNameFallback(t *testing.T) { + // No alt text -> "Video by @". + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no alt here", + withVideoFull(videoEmbedOpts{ + playlist: "https://video.bsky.app/p.m3u8", + })) + out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + video, ok := main["video"].(map[string]any) + if !ok { + t.Fatalf("video missing") + } + if video["name"] != "Video by @alice.bsky.social" { + t.Errorf("name fallback = %v", video["name"]) + } +} + +func TestBuildPostJSONLD_VideoNameFallbackHandleInvalid(t *testing.T) { + // handle.invalid + no alt -> generic fallback. + pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "x", + withVideoFull(videoEmbedOpts{ + playlist: "https://video.bsky.app/p.m3u8", + })) + out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + video := main["video"].(map[string]any) + if video["name"] != "Video on Bluesky" { + t.Errorf("name fallback = %v, want generic", video["name"]) + } +} + +func TestBuildPostJSONLD_VideoDescriptionFallback(t *testing.T) { + // Empty post text -> description falls back to name so Google's video + // rich-result requirement (description present) is satisfied. + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "", + withVideoFull(videoEmbedOpts{ + playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip", + })) + out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + video := main["video"].(map[string]any) + if video["description"] != "scenic clip" { + t.Errorf("description fallback = %v, want name", video["description"]) + } +} + +func TestBuildPostJSONLD_VideoNoAspectRatio(t *testing.T) { + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x", + withVideoFull(videoEmbedOpts{ + playlist: "https://video.bsky.app/p.m3u8", alt: "alt", + })) + out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + video := main["video"].(map[string]any) + if _, present := video["width"]; present { + t.Errorf("width should be omitted when AspectRatio missing") + } + if _, present := video["height"]; present { + t.Errorf("height should be omitted when AspectRatio missing") + } +} + +func TestBuildPostJSONLD_VideoMissingPlaylist(t *testing.T) { + // No playlist -> VideoObject suppressed; image[]/thumbnailUrl still set + // from the video thumb (existing behavior). + thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg" + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x", + withVideo(thumb)) + out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + if _, present := main["video"]; present { + t.Errorf("video without playlist should not produce VideoObject") + } + if main["thumbnailUrl"] != thumb { + t.Errorf("thumbnailUrl should still be set from video thumb, got %v", main["thumbnailUrl"]) + } +} + +func TestBuildPostJSONLD_VideoHiddenEmbed(t *testing.T) { + // Self-labeled hide drops the video, parallel to the image-hide test. + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw", + withVideoFull(videoEmbedOpts{ + thumbnail: "https://cdn.bsky.app/img/x@jpeg", + playlist: "https://video.bsky.app/p.m3u8", + alt: "should be dropped", + }), + withSelfLabel("porn")) + out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + if _, present := main["video"]; present { + t.Errorf("hidden-embed post should not emit video") + } +} + +func TestBuildPostJSONLD_VideoInRecordWithMedia(t *testing.T) { + thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg" + playlist := "https://video.bsky.app/p.m3u8" + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+video", + withVideoFull(videoEmbedOpts{ + thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true, + hasAspect: true, width: 4, height: 3, + })) + out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + video, ok := main["video"].(map[string]any) + if !ok { + t.Fatalf("video missing on record-with-media post") + } + if video["contentUrl"] != playlist { + t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist) + } + if video["thumbnailUrl"] != thumb { + t.Errorf("thumbnailUrl = %v, want %v", video["thumbnailUrl"], thumb) + } + // quote-post still surfaces via isBasedOn alongside the video. + if main["isBasedOn"] != "https://bsky.app/profile/quoted.bsky.social/post/q" { + t.Errorf("isBasedOn = %v", main["isBasedOn"]) + } +} + +func TestBuildPostJSONLD_VideoOnReply(t *testing.T) { + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") + *pv.ReplyCount = 1 + thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:bob/v@jpeg" + playlist := "https://video.bsky.app/bob.m3u8" + reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "watch", + withVideoFull(videoEmbedOpts{ + thumbnail: thumb, playlist: playlist, alt: "bob's clip", + })) + out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels) + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + c := main["comment"].([]any)[0].(map[string]any) + video, ok := c["video"].(map[string]any) + if !ok { + t.Fatalf("reply video missing") + } + if video["@type"] != "VideoObject" { + t.Errorf("reply video @type = %v", video["@type"]) + } + if video["name"] != "bob's clip" { + t.Errorf("reply video name = %v", video["name"]) + } + if video["contentUrl"] != playlist { + t.Errorf("reply video contentUrl = %v", video["contentUrl"]) + } + if video["embedUrl"] != "https://bsky.app/profile/bob.bsky.social/post/rep1" { + t.Errorf("reply video embedUrl = %v", video["embedUrl"]) + } +} + +func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) { + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed") + out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels) + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + if _, present := main["video"]; present { + t.Errorf("post without video should not include video field") + } +} + +func TestBuildProfileJSONLD_HasPartVideo(t *testing.T) { + pv := newProfileViewDetailed() + playlist := "https://video.bsky.app/p.m3u8" + post := makePostView("alice.bsky.social", "did:plc:alice", "rp1", "see", + withVideoFull(videoEmbedOpts{ + playlist: playlist, alt: "alt", + })) + out, _ := buildProfileJSONLD(pv, []*appbsky.FeedDefs_PostView{post}, hideEmbedLabels, hideReplyLabels) + page := unmarshalLD(t, out) + hp := page["hasPart"].([]any) + if len(hp) != 1 { + t.Fatalf("expected 1 hasPart entry, got %d", len(hp)) + } + video, ok := hp[0].(map[string]any)["video"].(map[string]any) + if !ok { + t.Fatalf("hasPart entry should carry video, got %v", hp[0]) + } + if video["contentUrl"] != playlist { + t.Errorf("hasPart video contentUrl = %v", video["contentUrl"]) + } + if video["embedUrl"] != "https://bsky.app/profile/alice.bsky.social/post/rp1" { + t.Errorf("hasPart video embedUrl = %v", video["embedUrl"]) + } +} + +// handle.invalid authors must still produce a non-empty embedUrl on the +// VideoObject. The handle-form URL is unusable, so we fall back to the +// DID-form URL derived from the AT-URI authority. Without this fallback, +// omitempty drops embedUrl and Google's video indexer loses the canonical +// page reference. +func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL(t *testing.T) { + playlist := "https://video.bsky.app/p.m3u8" + pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "watch", + withVideoFull(videoEmbedOpts{ + playlist: playlist, alt: "scenic clip", + })) + canonical := "https://bsky.app/profile/did:plc:alice/post/abc123" + out, _ := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels) + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + video, ok := main["video"].(map[string]any) + if !ok { + t.Fatalf("video missing on handle.invalid post") + } + if video["embedUrl"] != canonical { + t.Errorf("embedUrl = %v, want %v", video["embedUrl"], canonical) + } + if video["contentUrl"] != playlist { + t.Errorf("contentUrl = %v, want %v", video["contentUrl"], playlist) + } +} + +// Same fallback applies to videos on replies whose author is handle.invalid. +func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL_Reply(t *testing.T) { + pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main") + *pv.ReplyCount = 1 + playlist := "https://video.bsky.app/bob.m3u8" + reply := makePostView("handle.invalid", "did:plc:bob", "rep1", "watch", + withVideoFull(videoEmbedOpts{ + playlist: playlist, alt: "bob's clip", + })) + out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels) + main := unmarshalLD(t, out)["mainEntity"].(map[string]any) + c := main["comment"].([]any)[0].(map[string]any) + video, ok := c["video"].(map[string]any) + if !ok { + t.Fatalf("reply video missing") + } + want := "https://bsky.app/profile/did:plc:bob/post/rep1" + if video["embedUrl"] != want { + t.Errorf("reply video embedUrl = %v, want %v", video["embedUrl"], want) + } +}