From be76cbebe99f4f2d771b1fdf681fd36aeca420b6 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 2 Jun 2026 10:57:53 -0700 Subject: [PATCH] media: add likeCount to videoView; show it on the video listing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a likeCount field to place.stream.media.getVideo#videoView (lexicon + generated Go type). Both videoView builders populate it via GetLikeCount(uri): hydrateVideoView (getVideoList) and GetVideoView (getVideo), so the count rides along with the existing list/detail responses — no extra round-trip. Always present (zero when none), mirroring viewCounts. Surface it on the video index page: each VideoCard now shows "N views · M likes". The generated TS lexicon type (js/streamplace/src/lexicons, gitignored) is regenerated from the committed lexicon by `make js-lexicons`. Co-Authored-By: Claude Opus 4.8 --- js/app/components/video/video-card.tsx | 6 ++- lexicons/place/stream/media/getVideo.json | 7 ++- pkg/model/video.go | 6 +++ pkg/model/video_list.go | 6 +++ pkg/model/video_list_test.go | 53 +++++++++++++++++++++++ pkg/streamplace/mediagetVideo.go | 8 ++-- 6 files changed, 80 insertions(+), 6 deletions(-) diff --git a/js/app/components/video/video-card.tsx b/js/app/components/video/video-card.tsx index 64482d40..e6dd7058 100644 --- a/js/app/components/video/video-card.tsx +++ b/js/app/components/video/video-card.tsx @@ -10,7 +10,7 @@ import { } from "utils/video"; import AQLink from "../aqlink"; -function formatViews(count: number): string { +function formatCount(count: number): string { if (count >= 1_000_000) { return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}M`; } @@ -36,6 +36,7 @@ export default function VideoCard({ const thumbnailUrl = getVideoThumbnailUrl(record, author.did); const duration = formatDuration(record.durationMs); const viewCount = video.viewCounts?.count ?? 0; + const likeCount = video.likeCount ?? 0; return ( @@ -132,7 +133,8 @@ export default function VideoCard({ @{user} - {formatViews(viewCount)} view{viewCount === 1 ? "" : "s"} + {formatCount(viewCount)} view{viewCount === 1 ? "" : "s"} ·{" "} + {formatCount(likeCount)} like{likeCount === 1 ? "" : "s"} diff --git a/lexicons/place/stream/media/getVideo.json b/lexicons/place/stream/media/getVideo.json index 86f5ae1e..27e8a78b 100644 --- a/lexicons/place/stream/media/getVideo.json +++ b/lexicons/place/stream/media/getVideo.json @@ -32,7 +32,7 @@ }, "videoView": { "type": "object", - "required": ["uri", "cid", "author", "record", "viewCounts"], + "required": ["uri", "cid", "author", "record", "viewCounts", "likeCount"], "properties": { "uri": { "type": "string", "format": "at-uri" }, "cid": { "type": "string", "format": "cid" }, @@ -41,6 +41,11 @@ "ref": "app.bsky.actor.defs#profileViewBasic" }, "record": { "type": "unknown" }, + "likeCount": { + "type": "integer", + "minimum": 0, + "description": "Total number of place.stream.like records whose subject is this video. Always present; zero when none, so consumers can render a count unconditionally." + }, "viewCounts": { "type": "ref", "ref": "#viewCountSummary", diff --git a/pkg/model/video.go b/pkg/model/video.go index ae4f8436..fe8f9185 100644 --- a/pkg/model/video.go +++ b/pkg/model/video.go @@ -114,6 +114,11 @@ func (m *DBModel) GetVideoView(ctx context.Context, uri string) (*streamplace.Me return nil, err } + likeCount, err := m.GetLikeCount(ctx, uri) + if err != nil { + return nil, fmt.Errorf("get like count: %w", err) + } + tracks := []*streamplace.MediaTrack_TrackView{} if rec.Source.MediaDefs_SourceTracks != nil { for _, track := range rec.Source.MediaDefs_SourceTracks.Tracks { @@ -143,6 +148,7 @@ func (m *DBModel) GetVideoView(ctx context.Context, uri string) (*streamplace.Me Author: author, Record: &lexutil.LexiconTypeDecoder{Val: rec}, ViewCounts: summary, + LikeCount: likeCount, Tracks: tracks, }, nil } diff --git a/pkg/model/video_list.go b/pkg/model/video_list.go index f7271e9d..1a51906f 100644 --- a/pkg/model/video_list.go +++ b/pkg/model/video_list.go @@ -280,11 +280,17 @@ func (m *DBModel) hydrateVideoView(ctx context.Context, row *Video) (*streamplac return nil, err } + likeCount, err := m.GetLikeCount(ctx, row.URI) + if err != nil { + return nil, fmt.Errorf("get like count: %w", err) + } + return &streamplace.MediaGetVideo_VideoView{ Uri: row.URI, Cid: row.CID, Author: author, Record: &lexutil.LexiconTypeDecoder{Val: rec}, ViewCounts: summary, + LikeCount: likeCount, }, nil } diff --git a/pkg/model/video_list_test.go b/pkg/model/video_list_test.go index 0925bc33..878b49ea 100644 --- a/pkg/model/video_list_test.go +++ b/pkg/model/video_list_test.go @@ -3,6 +3,7 @@ package model import ( "context" "testing" + "time" comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/stretchr/testify/require" @@ -10,6 +11,58 @@ import ( "stream.place/streamplace/pkg/streamplace" ) +// putLike writes a place.stream.like whose subject is the given video URI. +func putLike(t *testing.T, m Model, subject, cid string) { + t.Helper() + now := time.Now().UTC() + require.NoError(t, m.CreateLike(context.Background(), &Like{ + CID: cid, + URI: "at://did:plc:liker/place.stream.like/" + cid, + Subject: subject, + RepoDID: "did:plc:liker", + IndexedAt: &now, + CreatedAt: now, + })) +} + +// TestVideoLikeCount verifies likeCount is populated (and subject-scoped) on +// both the single-video view and the listing. +func TestVideoLikeCount(t *testing.T) { + m, err := MakeDB(":memory:") + require.NoError(t, err) + ctx := context.Background() + + const videoURI = "at://did:plc:alice/place.stream.video/likeme" + putTrackVideo(t, m, videoURI, "at://did:plc:alice/place.stream.media.track/lt1", "blobLike") + + // Always present; zero before any likes. + view, err := m.GetVideoView(ctx, videoURI) + require.NoError(t, err) + require.NotNil(t, view) + require.Equal(t, int64(0), view.LikeCount) + + putLike(t, m, videoURI, "likecid1") + putLike(t, m, videoURI, "likecid2") + putLike(t, m, videoURI, "likecid3") + // A like on a different subject must not count toward this video. + putLike(t, m, "at://did:plc:alice/place.stream.video/other", "likecid4") + + view, err = m.GetVideoView(ctx, videoURI) + require.NoError(t, err) + require.Equal(t, int64(3), view.LikeCount) + + list, err := m.GetVideoList(ctx, "", 25, "", "") + require.NoError(t, err) + var found *streamplace.MediaGetVideo_VideoView + for _, v := range list.Videos { + if v.Uri == videoURI { + found = v + } + } + require.NotNil(t, found, "video should appear in unfiltered listing") + require.Equal(t, int64(3), found.LikeCount) +} + const testServerDID = "did:web:us.example.com" // putTrackVideo writes a place.stream.media.track (backed by blobCID) and a diff --git a/pkg/streamplace/mediagetVideo.go b/pkg/streamplace/mediagetVideo.go index 028b80e1..1e90a529 100644 --- a/pkg/streamplace/mediagetVideo.go +++ b/pkg/streamplace/mediagetVideo.go @@ -15,9 +15,11 @@ import ( type MediaGetVideo_VideoView struct { Author *appbsky.ActorDefs_ProfileViewBasic `json:"author" cborgen:"author"` Cid string `json:"cid" cborgen:"cid"` - Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"` - Tracks []*MediaTrack_TrackView `json:"tracks,omitempty" cborgen:"tracks,omitempty"` - Uri string `json:"uri" cborgen:"uri"` + // likeCount: Total number of place.stream.like records whose subject is this video. Always present; zero when none, so consumers can render a count unconditionally. + LikeCount int64 `json:"likeCount" cborgen:"likeCount"` + Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"` + Tracks []*MediaTrack_TrackView `json:"tracks,omitempty" cborgen:"tracks,omitempty"` + Uri string `json:"uri" cborgen:"uri"` // viewCounts: Aggregated view counts across every indexed reporter. Always present; zero-valued (count=0, reporters=0) when no place.stream.media.viewCount records have been observed yet, so consumers can render a count unconditionally. ViewCounts *MediaGetVideo_ViewCountSummary `json:"viewCounts" cborgen:"viewCounts"` } -- 2.51.2