From da458d4272a5006b40bcd5a8cdc339d0734b044e Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Sun, 17 May 2026 18:46:17 -0700 Subject: [PATCH] viewCount: always include the summary in videoView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes `viewCounts` to required on the videoView record and has the model return a zero-valued summary (count=0, reporters=0) rather than nil when no place.stream.media.viewCount records have been indexed yet. UIs can render the count unconditionally — no special-case for "data exists" vs "no data". Co-Authored-By: Claude Opus 4.7 --- .../media/place-stream-media-getvideo.md | 18 +++++++++--------- .../content/docs/lex-reference/openapi.json | 4 ++-- lexicons/place/stream/media/getVideo.json | 4 ++-- pkg/model/media_view_count.go | 15 +++++++-------- pkg/model/media_view_count_test.go | 6 +++++- pkg/streamplace/mediagetVideo.go | 4 ++-- 6 files changed, 27 insertions(+), 24 deletions(-) diff --git a/js/docs/src/content/docs/lex-reference/media/place-stream-media-getvideo.md b/js/docs/src/content/docs/lex-reference/media/place-stream-media-getvideo.md index 2e0ba1d5..ee6c54c8 100644 --- a/js/docs/src/content/docs/lex-reference/media/place-stream-media-getvideo.md +++ b/js/docs/src/content/docs/lex-reference/media/place-stream-media-getvideo.md @@ -42,13 +42,13 @@ Get a hydrated view of a place.stream.video record — the record itself plus au **Properties:** -| Name | Type | Req'd | Description | Constraints | -| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------- | -| `uri` | `string` | ✅ | | Format: `at-uri` | -| `cid` | `string` | ✅ | | Format: `cid` | -| `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ✅ | | | -| `record` | `unknown` | ✅ | | | -| `viewCounts` | [`#viewCountSummary`](#viewcountsummary) | ❌ | Aggregated view counts across every indexed reporter. Absent when no place.stream.media.viewCount records have been observed yet. | | +| Name | Type | Req'd | Description | Constraints | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | +| `uri` | `string` | ✅ | | Format: `at-uri` | +| `cid` | `string` | ✅ | | Format: `cid` | +| `author` | [`app.bsky.actor.defs#profileViewBasic`](https://github.com/bluesky-social/atproto/tree/main/lexicons/app/bsky/actor/defs.json#profileViewBasic) | ✅ | | | +| `record` | `unknown` | ✅ | | | +| `viewCounts` | [`#viewCountSummary`](#viewcountsummary) | ✅ | 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. | | --- @@ -108,7 +108,7 @@ Sums across every place.stream.media.viewCount record indexed for this video, re }, "videoView": { "type": "object", - "required": ["uri", "cid", "author", "record"], + "required": ["uri", "cid", "author", "record", "viewCounts"], "properties": { "uri": { "type": "string", @@ -128,7 +128,7 @@ Sums across every place.stream.media.viewCount record indexed for this video, re "viewCounts": { "type": "ref", "ref": "#viewCountSummary", - "description": "Aggregated view counts across every indexed reporter. Absent when no place.stream.media.viewCount records have been observed yet." + "description": "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." } } }, diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json index 41fea305..a9a97dec 100644 --- a/js/docs/src/content/docs/lex-reference/openapi.json +++ b/js/docs/src/content/docs/lex-reference/openapi.json @@ -4294,10 +4294,10 @@ "record": {}, "viewCounts": { "$ref": "#/components/schemas/place.stream.media.getVideo_viewCountSummary", - "description": "Aggregated view counts across every indexed reporter. Absent when no place.stream.media.viewCount records have been observed yet." + "description": "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." } }, - "required": ["uri", "cid", "author", "record"] + "required": ["uri", "cid", "author", "record", "viewCounts"] }, "app.bsky.actor.defs_profileViewBasic": { "type": "object", diff --git a/lexicons/place/stream/media/getVideo.json b/lexicons/place/stream/media/getVideo.json index 76e9f04e..d210cd52 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"], + "required": ["uri", "cid", "author", "record", "viewCounts"], "properties": { "uri": { "type": "string", "format": "at-uri" }, "cid": { "type": "string", "format": "cid" }, @@ -44,7 +44,7 @@ "viewCounts": { "type": "ref", "ref": "#viewCountSummary", - "description": "Aggregated view counts across every indexed reporter. Absent when no place.stream.media.viewCount records have been observed yet." + "description": "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." } } }, diff --git a/pkg/model/media_view_count.go b/pkg/model/media_view_count.go index f9e9e50f..47b4f064 100644 --- a/pkg/model/media_view_count.go +++ b/pkg/model/media_view_count.go @@ -90,11 +90,14 @@ func (m *DBModel) GetMediaViewCountByURI(ctx context.Context, uri string) (*stre // viewCountSummary sums every place.stream.media.viewCount record // indexed for the given video — across reporters and across windows -// — into the lexicon-defined summary shape. Returns (nil, nil) when -// no records exist so callers can distinguish "no data" from "zero -// data". Internal to the model package; consumers see only the -// hydrated VideoView from GetVideoView. +// — into the lexicon-defined summary shape. Always returns a non-nil +// summary (with zeroes when no records exist) so the view shape stays +// consistent: consumers can render `count` / `bytes` / `durationMs` +// unconditionally without a nil check. Internal to the model +// package; consumers see only the hydrated VideoView from +// GetVideoView. func (m *DBModel) viewCountSummary(ctx context.Context, videoURI string) (*streamplace.MediaGetVideo_ViewCountSummary, error) { + out := &streamplace.MediaGetVideo_ViewCountSummary{} var rows []*MediaViewCount err := m.DB.WithContext(ctx). Where("video_uri = ?", videoURI). @@ -102,10 +105,6 @@ func (m *DBModel) viewCountSummary(ctx context.Context, videoURI string) (*strea if err != nil { return nil, fmt.Errorf("list view counts for video: %w", err) } - if len(rows) == 0 { - return nil, nil - } - out := &streamplace.MediaGetVideo_ViewCountSummary{} reporters := make(map[string]struct{}) for _, row := range rows { rec, err := row.ToRecord() diff --git a/pkg/model/media_view_count_test.go b/pkg/model/media_view_count_test.go index 0f73519c..0174ff6d 100644 --- a/pkg/model/media_view_count_test.go +++ b/pkg/model/media_view_count_test.go @@ -63,7 +63,11 @@ func TestGetVideoView_NoViewCounts(t *testing.T) { require.NotNil(t, view) require.Equal(t, videoURI, view.Uri) require.Equal(t, owner, view.Author.Did) - require.Nil(t, view.ViewCounts, "no viewCount records ⇒ summary is omitted") + require.NotNil(t, view.ViewCounts, "summary is always present; zero-valued when no records") + require.Equal(t, int64(0), view.ViewCounts.Count) + require.Equal(t, int64(0), view.ViewCounts.Bytes) + require.Equal(t, int64(0), view.ViewCounts.DurationMs) + require.Equal(t, int64(0), view.ViewCounts.Reporters) } func TestGetVideoView_SumsAcrossReporters(t *testing.T) { diff --git a/pkg/streamplace/mediagetVideo.go b/pkg/streamplace/mediagetVideo.go index 7f322ff0..7f365c97 100644 --- a/pkg/streamplace/mediagetVideo.go +++ b/pkg/streamplace/mediagetVideo.go @@ -17,8 +17,8 @@ type MediaGetVideo_VideoView struct { Cid string `json:"cid" cborgen:"cid"` Record *lexutil.LexiconTypeDecoder `json:"record" cborgen:"record"` Uri string `json:"uri" cborgen:"uri"` - // viewCounts: Aggregated view counts across every indexed reporter. Absent when no place.stream.media.viewCount records have been observed yet. - ViewCounts *MediaGetVideo_ViewCountSummary `json:"viewCounts,omitempty" cborgen:"viewCounts,omitempty"` + // 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"` } // MediaGetVideo_ViewCountSummary is a "viewCountSummary" in the place.stream.media.getVideo schema. -- 2.51.2