bskyweb: add video objects to jsonld SEO (#10814)
This commit is contained in:
@@ -4,10 +4,9 @@ import (
|
|||||||
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
appbsky "github.com/bluesky-social/indigo/api/bsky"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Helpers for extracting Open Graph metadata from post embeds. These feed
|
// Helpers for extracting Open Graph metadata from post embeds. og:video
|
||||||
// og:* / twitter:* meta tags only; the schema.org JSON-LD output lives in
|
// shares findVideoEmbed with the JSON-LD VideoObject path in jsonld.go so
|
||||||
// jsonld.go. og:video has its own path because JSON-LD does not yet emit
|
// the two outputs cannot drift.
|
||||||
// VideoObject (deferred — needs `duration` from the appview).
|
|
||||||
|
|
||||||
// videoMeta holds og:video meta tag data.
|
// videoMeta holds og:video meta tag data.
|
||||||
type videoMeta struct {
|
type videoMeta struct {
|
||||||
@@ -21,17 +20,7 @@ type videoMeta struct {
|
|||||||
// extractVideoMeta returns og:video metadata, or the zero value if there's
|
// 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.
|
// no video embed. Respects embedHidden so og:video and og:image stay in sync.
|
||||||
func extractVideoMeta(pv *appbsky.FeedDefs_PostView, embedHidden bool) videoMeta {
|
func extractVideoMeta(pv *appbsky.FeedDefs_PostView, embedHidden bool) videoMeta {
|
||||||
if pv == nil || pv.Embed == nil || embedHidden {
|
v := findVideoEmbed(pv, 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
|
|
||||||
}
|
|
||||||
if v == nil || v.Playlist == "" {
|
if v == nil || v.Playlist == "" {
|
||||||
return videoMeta{}
|
return videoMeta{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ type discussionForumPosting struct {
|
|||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
Image []string `json:"image,omitempty"`
|
Image []string `json:"image,omitempty"`
|
||||||
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
||||||
|
Video *videoObject `json:"video,omitempty"`
|
||||||
DatePublished string `json:"datePublished,omitempty"`
|
DatePublished string `json:"datePublished,omitempty"`
|
||||||
InteractionStat []interactionStat `json:"interactionStatistic,omitempty"`
|
InteractionStat []interactionStat `json:"interactionStatistic,omitempty"`
|
||||||
CommentCount *int64 `json:"commentCount,omitempty"`
|
CommentCount *int64 `json:"commentCount,omitempty"`
|
||||||
@@ -67,6 +68,20 @@ type discussionForumPosting struct {
|
|||||||
SharedContent *sharedContent `json:"sharedContent,omitempty"`
|
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
|
// comment is the schema.org Comment shape used in
|
||||||
// DiscussionForumPosting.comment[]. The comment property does not accept
|
// DiscussionForumPosting.comment[]. The comment property does not accept
|
||||||
// DiscussionForumPosting, so replies map to Comment.
|
// DiscussionForumPosting, so replies map to Comment.
|
||||||
@@ -78,6 +93,7 @@ type comment struct {
|
|||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
Image []string `json:"image,omitempty"`
|
Image []string `json:"image,omitempty"`
|
||||||
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
|
||||||
|
Video *videoObject `json:"video,omitempty"`
|
||||||
DatePublished string `json:"datePublished,omitempty"`
|
DatePublished string `json:"datePublished,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +143,31 @@ func bskyPostURLFromATURI(handle, atURI string) string {
|
|||||||
return bskyPostURL(handle, parsed.RecordKey().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
|
// bskyProfileURL returns the canonical handle-form profile URL, or "" if
|
||||||
// the handle is unusable.
|
// the handle is unusable.
|
||||||
func bskyProfileURL(handle string) string {
|
func bskyProfileURL(handle string) string {
|
||||||
@@ -205,6 +246,64 @@ func galleryThumbs(items []*appbsky.EmbedGallery_View_Items_Elem) []string {
|
|||||||
return urls
|
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 @<handle>" 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 ""
|
// extractQuotedPostURL returns the canonical URL of a quoted post, or ""
|
||||||
// if the embed is blocked / not-found / detached / a non-post record.
|
// if the embed is blocked / not-found / detached / a non-post record.
|
||||||
func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
|
func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
|
||||||
@@ -394,14 +493,18 @@ func buildPostNode(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_Th
|
|||||||
thumb = images[0]
|
thumb = images[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
postURL := bskyPostURLFromATURIWithDIDFallback(pv.Author.Handle, pv.Uri)
|
||||||
|
postText := postRecordText(pv)
|
||||||
|
|
||||||
node := discussionForumPosting{
|
node := discussionForumPosting{
|
||||||
Type: "DiscussionForumPosting",
|
Type: "DiscussionForumPosting",
|
||||||
URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
|
URL: postURL,
|
||||||
Identifier: pv.Uri,
|
Identifier: pv.Uri,
|
||||||
Author: buildAuthor(pv.Author),
|
Author: buildAuthor(pv.Author),
|
||||||
Text: postRecordText(pv),
|
Text: postText,
|
||||||
Image: images,
|
Image: images,
|
||||||
ThumbnailURL: thumb,
|
ThumbnailURL: thumb,
|
||||||
|
Video: buildVideoObject(pv, postURL, postText, embedHidden),
|
||||||
DatePublished: pv.IndexedAt,
|
DatePublished: pv.IndexedAt,
|
||||||
InteractionStat: buildPostStats(pv),
|
InteractionStat: buildPostStats(pv),
|
||||||
}
|
}
|
||||||
@@ -464,14 +567,17 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c
|
|||||||
if len(images) > 0 {
|
if len(images) > 0 {
|
||||||
thumb = images[0]
|
thumb = images[0]
|
||||||
}
|
}
|
||||||
|
postURL := bskyPostURLFromATURIWithDIDFallback(pv.Author.Handle, pv.Uri)
|
||||||
|
postText := postRecordText(pv)
|
||||||
return comment{
|
return comment{
|
||||||
Type: "Comment",
|
Type: "Comment",
|
||||||
URL: bskyPostURLFromATURI(pv.Author.Handle, pv.Uri),
|
URL: postURL,
|
||||||
Identifier: pv.Uri,
|
Identifier: pv.Uri,
|
||||||
Author: buildAuthor(pv.Author),
|
Author: buildAuthor(pv.Author),
|
||||||
Text: postRecordText(pv),
|
Text: postText,
|
||||||
Image: images,
|
Image: images,
|
||||||
ThumbnailURL: thumb,
|
ThumbnailURL: thumb,
|
||||||
|
Video: buildVideoObject(pv, postURL, postText, embedHidden),
|
||||||
DatePublished: pv.IndexedAt,
|
DatePublished: pv.IndexedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1204,3 +1204,339 @@ func TestBuildProfileJSONLD_HasPartAuthorReviewedBy(t *testing.T) {
|
|||||||
t.Errorf("verifier identifier wrong: %v", rb[0])
|
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 @<handle>".
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user