From 73f0fdf8989b6da05dfd65d3944feba274f07073 Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Fri, 15 May 2026 15:02:10 -0700 Subject: [PATCH] playback: tag playlist + segment URLs with a per-session tid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Master-playlist requests mint a fresh atproto TID; that sid is embedded in every sub-playlist + getVideoBlob URL the playlist references, so follow-up media-playlist + segment fetches can be correlated back to one playback session. View-count accounting will hang off this later — this commit is just the plumbing. A caller-supplied sid is reused after passing syntax.ParseTID, so a player that follows our master-playlist URLs carries the same id through without us minting a new one each hop. Co-Authored-By: Claude Opus 4.7 --- .../content/docs/lex-reference/openapi.json | 20 ++++ .../place-stream-playback-getvideoblob.md | 13 ++- .../place-stream-playback-getvideoplaylist.md | 17 ++- .../place/stream/playback/getVideoBlob.json | 4 + .../stream/playback/getVideoPlaylist.json | 4 + pkg/spid/tid.go | 13 +++ pkg/spxrpc/labels_test.go | 6 +- pkg/spxrpc/place_stream_playback_getvideo.go | 70 ++++++++--- .../place_stream_playback_getvideo_test.go | 110 ++++++++++++++++-- pkg/spxrpc/stubs.go | 10 +- pkg/streamplace/playbackgetVideoBlob.go | 6 +- pkg/streamplace/playbackgetVideoPlaylist.go | 6 +- 12 files changed, 236 insertions(+), 43 deletions(-) diff --git a/js/docs/src/content/docs/lex-reference/openapi.json b/js/docs/src/content/docs/lex-reference/openapi.json index 6d3037f19..92d79f0e5 100644 --- a/js/docs/src/content/docs/lex-reference/openapi.json +++ b/js/docs/src/content/docs/lex-reference/openapi.json @@ -742,6 +742,16 @@ "type": "string", "description": "BLAKE-3 BDASL CID of the requested blob." } + }, + { + "name": "sid", + "in": "query", + "required": false, + "description": "Opaque playback session identifier, propagated unmodified from the media playlist that referenced this blob. Logged for view-count correlation; not used for access control.", + "schema": { + "type": "string", + "description": "Opaque playback session identifier, propagated unmodified from the media playlist that referenced this blob. Logged for view-count correlation; not used for access control." + } } ] } @@ -835,6 +845,16 @@ "type": "integer", "description": "End time in nanoseconds. Omit to include all remaining content." } + }, + { + "name": "sid", + "in": "query", + "required": false, + "description": "Opaque playback session identifier. Omit on the master playlist request; the server generates one and embeds it in every sub-playlist URL it returns. Players never have to construct it themselves — they just follow the URLs the master playlist hands them, which carry the sid into media-playlist + segment requests. Used downstream to correlate a player's playlist + segment fetches for view-count accounting.", + "schema": { + "type": "string", + "description": "Opaque playback session identifier. Omit on the master playlist request; the server generates one and embeds it in every sub-playlist URL it returns. Players never have to construct it themselves — they just follow the URLs the master playlist hands them, which carry the sid into media-playlist + segment requests. Used downstream to correlate a player's playlist + segment fetches for view-count accounting." + } } ] } diff --git a/js/docs/src/content/docs/lex-reference/playback/place-stream-playback-getvideoblob.md b/js/docs/src/content/docs/lex-reference/playback/place-stream-playback-getvideoblob.md index f30540920..4d6c0b287 100644 --- a/js/docs/src/content/docs/lex-reference/playback/place-stream-playback-getvideoblob.md +++ b/js/docs/src/content/docs/lex-reference/playback/place-stream-playback-getvideoblob.md @@ -17,10 +17,11 @@ Fetch a content-addressed MUXL playback blob by CID. Serves either a primary fMP **Parameters:** -| Name | Type | Req'd | Description | Constraints | -| ----- | -------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | -| `did` | `string` | ✅ | DID of the account that owns the record this blob is being served for. Used for egress/usage accounting, not for access control — the blob is served purely by CID. | Format: `did` | -| `cid` | `string` | ✅ | BLAKE-3 BDASL CID of the requested blob. | | +| Name | Type | Req'd | Description | Constraints | +| ----- | -------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| `did` | `string` | ✅ | DID of the account that owns the record this blob is being served for. Used for egress/usage accounting, not for access control — the blob is served purely by CID. | Format: `did` | +| `cid` | `string` | ✅ | BLAKE-3 BDASL CID of the requested blob. | | +| `sid` | `string` | ❌ | Opaque playback session identifier, propagated unmodified from the media playlist that referenced this blob. Logged for view-count correlation; not used for access control. | | **Output:** @@ -56,6 +57,10 @@ _Schema not defined._ "cid": { "type": "string", "description": "BLAKE-3 BDASL CID of the requested blob." + }, + "sid": { + "type": "string", + "description": "Opaque playback session identifier, propagated unmodified from the media playlist that referenced this blob. Logged for view-count correlation; not used for access control." } } }, diff --git a/js/docs/src/content/docs/lex-reference/playback/place-stream-playback-getvideoplaylist.md b/js/docs/src/content/docs/lex-reference/playback/place-stream-playback-getvideoplaylist.md index c5fb36cd8..3309e782f 100644 --- a/js/docs/src/content/docs/lex-reference/playback/place-stream-playback-getvideoplaylist.md +++ b/js/docs/src/content/docs/lex-reference/playback/place-stream-playback-getvideoplaylist.md @@ -17,12 +17,13 @@ Get an HLS CMAF playlist for a video. Returns a master playlist when `track` is **Parameters:** -| Name | Type | Req'd | Description | Constraints | -| ------- | --------- | ----- | ----------------------------------------------------------------------------------------------------------------------- | ---------------- | -| `uri` | `string` | ✅ | AT-URI of the record to play back (e.g. at://did:plc:.../place.stream.video/). | Format: `at-uri` | -| `track` | `string` | ❌ | Track ID (stringified u32 matching the MUXL container) for a single-track media playlist. Omit for the master playlist. | | -| `start` | `integer` | ❌ | Start time in nanoseconds from the beginning of the video. Defaults to 0. | | -| `end` | `integer` | ❌ | End time in nanoseconds. Omit to include all remaining content. | | +| Name | Type | Req'd | Description | Constraints | +| ------- | --------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `uri` | `string` | ✅ | AT-URI of the record to play back (e.g. at://did:plc:.../place.stream.video/). | Format: `at-uri` | +| `track` | `string` | ❌ | Track ID (stringified u32 matching the MUXL container) for a single-track media playlist. Omit for the master playlist. | | +| `start` | `integer` | ❌ | Start time in nanoseconds from the beginning of the video. Defaults to 0. | | +| `end` | `integer` | ❌ | End time in nanoseconds. Omit to include all remaining content. | | +| `sid` | `string` | ❌ | Opaque playback session identifier. Omit on the master playlist request; the server generates one and embeds it in every sub-playlist URL it returns. Players never have to construct it themselves — they just follow the URLs the master playlist hands them, which carry the sid into media-playlist + segment requests. Used downstream to correlate a player's playlist + segment fetches for view-count accounting. | | **Output:** @@ -69,6 +70,10 @@ _Schema not defined._ "end": { "type": "integer", "description": "End time in nanoseconds. Omit to include all remaining content." + }, + "sid": { + "type": "string", + "description": "Opaque playback session identifier. Omit on the master playlist request; the server generates one and embeds it in every sub-playlist URL it returns. Players never have to construct it themselves — they just follow the URLs the master playlist hands them, which carry the sid into media-playlist + segment requests. Used downstream to correlate a player's playlist + segment fetches for view-count accounting." } } }, diff --git a/lexicons/place/stream/playback/getVideoBlob.json b/lexicons/place/stream/playback/getVideoBlob.json index 9d7124fd6..3664950a5 100644 --- a/lexicons/place/stream/playback/getVideoBlob.json +++ b/lexicons/place/stream/playback/getVideoBlob.json @@ -17,6 +17,10 @@ "cid": { "type": "string", "description": "BLAKE-3 BDASL CID of the requested blob." + }, + "sid": { + "type": "string", + "description": "Opaque playback session identifier, propagated unmodified from the media playlist that referenced this blob. Logged for view-count correlation; not used for access control." } } }, diff --git a/lexicons/place/stream/playback/getVideoPlaylist.json b/lexicons/place/stream/playback/getVideoPlaylist.json index d87771823..c47097d00 100644 --- a/lexicons/place/stream/playback/getVideoPlaylist.json +++ b/lexicons/place/stream/playback/getVideoPlaylist.json @@ -25,6 +25,10 @@ "end": { "type": "integer", "description": "End time in nanoseconds. Omit to include all remaining content." + }, + "sid": { + "type": "string", + "description": "Opaque playback session identifier. Omit on the master playlist request; the server generates one and embeds it in every sub-playlist URL it returns. Players never have to construct it themselves — they just follow the URLs the master playlist hands them, which carry the sid into media-playlist + segment requests. Used downstream to correlate a player's playlist + segment fetches for view-count accounting." } } }, diff --git a/pkg/spid/tid.go b/pkg/spid/tid.go index ed574308c..277811b69 100644 --- a/pkg/spid/tid.go +++ b/pkg/spid/tid.go @@ -17,3 +17,16 @@ func init() { clock := syntax.NewTIDClock(uint(id.Uint64())) TIDClock = &clock } + +// tid with a random clock id because i'm convinced that's a good idea +// so as to not leak internal infrastructure - don't you not want to +// know where a tid is coming from? idk. +func TID() string { + id, err := rand.Int(rand.Reader, big.NewInt(1024)) + if err != nil { + // absence of entropy or ram i guess? seems like a legit panic + panic(err) + } + clock := syntax.NewTIDClock(uint(id.Uint64())) + return clock.Next().String() +} diff --git a/pkg/spxrpc/labels_test.go b/pkg/spxrpc/labels_test.go index a3cd8cbce..89d0b1170 100644 --- a/pkg/spxrpc/labels_test.go +++ b/pkg/spxrpc/labels_test.go @@ -19,9 +19,9 @@ import ( ) const ( - testLabeler = "did:plc:labeler" - testOwner = "did:plc:owner" - testOtherUser = "did:plc:someoneelse" + testLabeler = "did:plc:labeler" + testOwner = "did:plc:owner" + testOtherUser = "did:plc:someoneelse" testContentCID = "bafkrcontentblob" testInitCID = "bafkrinitsegment" ) diff --git a/pkg/spxrpc/place_stream_playback_getvideo.go b/pkg/spxrpc/place_stream_playback_getvideo.go index 6036ada8e..ee7a1c5c0 100644 --- a/pkg/spxrpc/place_stream_playback_getvideo.go +++ b/pkg/spxrpc/place_stream_playback_getvideo.go @@ -18,10 +18,24 @@ import ( "stream.place/streamplace/pkg/blob" "stream.place/streamplace/pkg/log" + "stream.place/streamplace/pkg/spid" "stream.place/streamplace/pkg/streamplace" "stream.place/streamplace/pkg/vod" ) +// sessionIDOrNew returns the caller's sid if it's well-formed, otherwise +// gives you a fresh tid +func sessionIDOrNew(supplied string) (string, error) { + if supplied != "" { + _, err := syntax.ParseTID(supplied) + if err != nil { + return "", fmt.Errorf("invalid session ID: %w", err) + } + return supplied, nil + } + return spid.TID(), nil +} + // videoCollection is the only collection getVideoPlaylist currently // knows how to dispatch on. The lexicon accepts any AT-URI so we have // room to grow into other playable record types without a wire break. @@ -47,11 +61,11 @@ func stubMisrouted(name string) error { name+" auto-stub should have been overridden by NewServer; this is a wiring bug") } -func (s *Server) handlePlaceStreamPlaybackGetVideoBlob(ctx context.Context, cid string, did string) (io.Reader, error) { +func (s *Server) handlePlaceStreamPlaybackGetVideoBlob(ctx context.Context, cid string, did string, sid string) (io.Reader, error) { return nil, stubMisrouted("getVideoBlob") } -func (s *Server) handlePlaceStreamPlaybackGetVideoPlaylist(ctx context.Context, end *int, start *int, track string, uri string) (io.Reader, error) { +func (s *Server) handlePlaceStreamPlaybackGetVideoPlaylist(ctx context.Context, end *int, sid string, start *int, track string, uri string) (io.Reader, error) { return nil, stubMisrouted("getVideoPlaylist") } @@ -261,6 +275,15 @@ func (s *Server) HandleGetVideoPlaylist(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "start must be less than end") } + // Reuse the caller's sid when present (i.e. the player followed a + // URL we previously handed it) so the master/media/segment requests + // of one playback session share an identifier. Generate a fresh one + // when the player is making its first request. + sid, err := sessionIDOrNew(c.QueryParam("sid")) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + uri := aturi.String() // Labeler enforcement: any label on the video record, or a ban on @@ -289,9 +312,9 @@ func (s *Server) HandleGetVideoPlaylist(c echo.Context) error { var body string if track == "" { - body = masterPlaylist(meta, uri, startNS, endNS) + body = masterPlaylist(meta, uri, sid, startNS, endNS) } else { - body, err = mediaPlaylist(meta, track, ownerDID.String(), startNS, endNS) + body, err = mediaPlaylist(meta, track, ownerDID.String(), sid, startNS, endNS) if err != nil { return err } @@ -398,24 +421,34 @@ func (s *Server) fetchMetafile(ctx context.Context, cid string) (*vod.Metafile, // blobURL is where the .m3u8 references segments + init blobs. Same // node, getVideoBlob endpoint, content-addressed. `did` is the owning // account, carried so getVideoBlob can attribute egress without a -// cid -> media.origin lookup. +// cid -> media.origin lookup. `sid` is the playback session identifier +// the handler will eventually correlate against playlist requests for +// view-count accounting. // // The trailing `.m4s` is cosmetic but load-bearing: ffmpeg's HLS // demuxer refuses to fetch segment URLs whose extension isn't in its // `allowed_segment_extensions` list, and it checks the *end* of the // URL string. So `cid` (which carries the suffix) must be the last -// query param — we can't let url.Values sort `did` after it. The +// query param — we can't let url.Values sort other keys after it. The // handler strips the suffix back off before looking up the blob. -func blobURL(did, cid string) string { +func blobURL(did, cid, sid string) string { q := url.Values{"did": {did}} + if sid != "" { + q.Set("sid", sid) + } return "/xrpc/place.stream.playback.getVideoBlob?" + q.Encode() + "&cid=" + url.QueryEscape(cid) + ".m4s" } // trackPlaylistURL is the URL to a single-track media playlist served -// by this same handler. -func trackPlaylistURL(uri, track string, startNS, endNS *int64) string { +// by this same handler. `sid` is propagated from the master playlist +// so the player's media-playlist + segment requests all share an +// identifier. +func trackPlaylistURL(uri, track, sid string, startNS, endNS *int64) string { q := url.Values{"uri": {uri}, "track": {track}} + if sid != "" { + q.Set("sid", sid) + } if startNS != nil { q.Set("start", strconv.FormatInt(*startNS, 10)) } @@ -428,8 +461,10 @@ func trackPlaylistURL(uri, track string, startNS, endNS *int64) string { // masterPlaylist emits an HLS multi-variant master, one EXT-X-STREAM-INF // per video track and one EXT-X-MEDIA per audio track. Time-range // params (if present) are propagated to the per-track media playlist -// URLs so the player keeps the clip bounds. -func masterPlaylist(meta *vod.Metafile, uri string, startNS, endNS *int64) string { +// URLs so the player keeps the clip bounds. `sid` is threaded into +// every sub-playlist URL so the per-track requests + downstream +// segment fetches share one playback-session identifier. +func masterPlaylist(meta *vod.Metafile, uri, sid string, startNS, endNS *int64) string { lines := []string{"#EXTM3U", "#EXT-X-VERSION:6", ""} // Collect audio tracks in deterministic order, pick a default @@ -444,7 +479,7 @@ func masterPlaylist(meta *vod.Metafile, uri string, startNS, endNS *int64) strin t.Codec, yesNo(isDefault), fmt.Sprintf("%d", channelsOrDefault(t.Channels)), - trackPlaylistURL(uri, tid, startNS, endNS), + trackPlaylistURL(uri, tid, sid, startNS, endNS), )) } if len(audioIDs) > 0 { @@ -476,7 +511,7 @@ func masterPlaylist(meta *vod.Metafile, uri string, startNS, endNS *int64) strin streamInf += `,AUDIO="audio"` } lines = append(lines, streamInf) - lines = append(lines, trackPlaylistURL(uri, tid, startNS, endNS)) + lines = append(lines, trackPlaylistURL(uri, tid, sid, startNS, endNS)) } return strings.Join(lines, "\n") + "\n" @@ -485,8 +520,9 @@ func masterPlaylist(meta *vod.Metafile, uri string, startNS, endNS *int64) strin // mediaPlaylist emits a single-track HLS playlist with one // EXT-X-BYTERANGE per segment pointing at getVideoBlob, plus an // EXT-X-MAP for the per-track init segment. `ownerDID` is threaded -// into every getVideoBlob URL for egress accounting. -func mediaPlaylist(meta *vod.Metafile, trackID, ownerDID string, startNS, endNS *int64) (string, error) { +// into every getVideoBlob URL for egress accounting, and `sid` for +// view-count correlation against the request that built this playlist. +func mediaPlaylist(meta *vod.Metafile, trackID, ownerDID, sid string, startNS, endNS *int64) (string, error) { t, ok := meta.Tracks[trackID] if !ok { return "", echo.NewHTTPError(http.StatusNotFound, "TrackNotFound") @@ -515,10 +551,10 @@ func mediaPlaylist(meta *vod.Metafile, trackID, ownerDID string, startNS, endNS "#EXT-X-INDEPENDENT-SEGMENTS", fmt.Sprintf("#EXT-X-TARGETDURATION:%d", targetDuration), "#EXT-X-MEDIA-SEQUENCE:0", - fmt.Sprintf(`#EXT-X-MAP:URI=%q`, blobURL(ownerDID, t.InitCID)), + fmt.Sprintf(`#EXT-X-MAP:URI=%q`, blobURL(ownerDID, t.InitCID, sid)), "", } - bURL := blobURL(ownerDID, t.BlobCID) + bURL := blobURL(ownerDID, t.BlobCID, sid) for _, seg := range segments { durSec := float64(seg.DurationTicks) / float64(t.Timescale) lines = append(lines, diff --git a/pkg/spxrpc/place_stream_playback_getvideo_test.go b/pkg/spxrpc/place_stream_playback_getvideo_test.go index e33ac045a..509ecc648 100644 --- a/pkg/spxrpc/place_stream_playback_getvideo_test.go +++ b/pkg/spxrpc/place_stream_playback_getvideo_test.go @@ -1,11 +1,18 @@ package spxrpc import ( + "net/http" + "net/http/httptest" + "net/url" "strings" "testing" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/labstack/echo/v4" "github.com/stretchr/testify/require" + "stream.place/streamplace/pkg/blob" + "stream.place/streamplace/pkg/spid" "stream.place/streamplace/pkg/vod" ) @@ -52,10 +59,14 @@ func fixtureMetafile() *vod.Metafile { const ( fixtureURI = "at://did:plc:abc/place.stream.video/rkey1" fixtureDID = "did:plc:abc" + // A real atproto TID — the same shape the handler hands out via + // spid.TID() — so URL-embedding round-trips through ParseTID + // without any encoding surprises. + fixtureSID = "3jzfcijpj2z2a" ) func TestMasterPlaylist(t *testing.T) { - pl := masterPlaylist(fixtureMetafile(), fixtureURI, nil, nil) + pl := masterPlaylist(fixtureMetafile(), fixtureURI, fixtureSID, nil, nil) require.Contains(t, pl, "#EXTM3U") require.Contains(t, pl, "#EXT-X-VERSION:6") // Audio media line for the default (AAC) track. @@ -73,17 +84,22 @@ func TestMasterPlaylist(t *testing.T) { require.Contains(t, pl, "uri=at%3A%2F%2Fdid%3Aplc%3Aabc%2Fplace.stream.video%2Frkey1") require.Contains(t, pl, `track=1`) require.Contains(t, pl, `track=2`) + // Session ID gets pushed into every per-track playlist URL so the + // follow-up media-playlist + segment requests can be correlated. + require.Contains(t, pl, "sid="+fixtureSID) + require.Equal(t, 2, strings.Count(pl, "sid="+fixtureSID), + "sid should appear once per track URL (audio + video variant)") } func TestMasterPlaylist_PropagatesTimeRange(t *testing.T) { start, end := int64(1_000_000_000), int64(2_000_000_000) - pl := masterPlaylist(fixtureMetafile(), fixtureURI, &start, &end) + pl := masterPlaylist(fixtureMetafile(), fixtureURI, fixtureSID, &start, &end) require.Contains(t, pl, "start=1000000000") require.Contains(t, pl, "end=2000000000") } func TestMediaPlaylist_Video(t *testing.T) { - pl, err := mediaPlaylist(fixtureMetafile(), "1", fixtureDID, nil, nil) + pl, err := mediaPlaylist(fixtureMetafile(), "1", fixtureDID, fixtureSID, nil, nil) require.NoError(t, err) require.Contains(t, pl, "#EXT-X-PLAYLIST-TYPE:VOD") require.Contains(t, pl, "#EXT-X-INDEPENDENT-SEGMENTS") @@ -95,8 +111,11 @@ func TestMediaPlaylist_Video(t *testing.T) { // Owner DID carried for egress accounting. require.Contains(t, pl, "did=did%3Aplc%3Aabc") // cid must stay the last query param so the URL ends in `.m4s`; - // `.m4s&` would mean another param got sorted after it. + // `.m4s&` would mean another param got sorted after it. The sid + // gets sorted between did and cid, so it can't break this. require.NotContains(t, pl, ".m4s&") + // Session ID is on every segment + init URL. + require.Contains(t, pl, "sid="+fixtureSID) require.Contains(t, pl, `#EXT-X-BYTERANGE:2000@100`) require.Contains(t, pl, `#EXT-X-BYTERANGE:1800@2100`) require.Contains(t, pl, "#EXTINF:1.000000,") @@ -104,7 +123,7 @@ func TestMediaPlaylist_Video(t *testing.T) { } func TestMediaPlaylist_UnknownTrack(t *testing.T) { - _, err := mediaPlaylist(fixtureMetafile(), "99", fixtureDID, nil, nil) + _, err := mediaPlaylist(fixtureMetafile(), "99", fixtureDID, fixtureSID, nil, nil) require.Error(t, err) require.Contains(t, err.Error(), "TrackNotFound") } @@ -114,12 +133,59 @@ func TestMediaPlaylist_TimeRangeFilters(t *testing.T) { // ticks each). Ask for [1s, 2s): should keep only segment 1. start := int64(1_000_000_000) end := int64(2_000_000_000) - pl, err := mediaPlaylist(fixtureMetafile(), "1", fixtureDID, &start, &end) + pl, err := mediaPlaylist(fixtureMetafile(), "1", fixtureDID, fixtureSID, &start, &end) require.NoError(t, err) require.NotContains(t, pl, `#EXT-X-BYTERANGE:2000@100`) require.Contains(t, pl, `#EXT-X-BYTERANGE:1800@2100`) } +func TestMediaPlaylist_EmptySIDOmitsParam(t *testing.T) { + // Belt-and-suspenders for direct callers: passing an empty sid + // shouldn't put a stray `sid=` in the URLs (URL-builder uses + // url.Values.Set only when non-empty). + pl, err := mediaPlaylist(fixtureMetafile(), "1", fixtureDID, "", nil, nil) + require.NoError(t, err) + require.NotContains(t, pl, "sid=") +} + +func TestSessionIDOrNew(t *testing.T) { + t.Run("supplied is reused", func(t *testing.T) { + // A well-formed TID like the player would have gotten from a + // prior master-playlist response. + valid := spid.TID() + got, err := sessionIDOrNew(valid) + require.NoError(t, err) + require.Equal(t, valid, got) + }) + t.Run("empty mints a fresh tid", func(t *testing.T) { + got, err := sessionIDOrNew("") + require.NoError(t, err) + // The fresh sid is itself a valid TID — players that round-trip + // it through ParseTID won't reject our output. + _, err = syntax.ParseTID(got) + require.NoError(t, err) + // TIDs embed a timestamp + random clock id, so back-to-back + // calls never collide. + got2, err := sessionIDOrNew("") + require.NoError(t, err) + require.NotEqual(t, got, got2) + }) + t.Run("invalid is rejected", func(t *testing.T) { + for _, bad := range []string{ + "has space", // whitespace + "has/slash", // URL-illegal char + "has?question", // ditto + "tooshort", // < 13 chars + "toolongtoolong", // > 13 chars + "1111111111111", // 13 chars but '1' isn't in the TID alphabet + "zabcdefghijkl", // 13 chars but 'z' isn't a valid first char + } { + _, err := sessionIDOrNew(bad) + require.Error(t, err, "expected %q to be rejected", bad) + } + }) +} + func TestParseSingleRange(t *testing.T) { cases := []struct { name string @@ -153,9 +219,39 @@ func TestParseSingleRange(t *testing.T) { } } +// TestHandleGetVideoPlaylist_RejectsBadSID exercises the handler-side +// validation. Validation runs before any DB / blob lookups, so the +// test doesn't need a populated model or playbackStore content — just +// the playbackStore presence check the handler does up front. +func TestHandleGetVideoPlaylist_RejectsBadSID(t *testing.T) { + store, err := blob.NewFileStore(t.TempDir()) + require.NoError(t, err) + s := &Server{playbackStore: store} + + for _, tc := range []struct { + name string + sid string + }{ + {"contains space", "has space"}, + {"contains slash", "has/slash"}, + {"too long", strings.Repeat("x", 65)}, + {"wrong tid alphabet", "1111111111111"}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, + "/xrpc/place.stream.playback.getVideoPlaylist?uri="+ + "at%3A%2F%2Fdid%3Aplc%3Aabc%2Fplace.stream.video%2Fr1"+ + "&sid="+url.QueryEscape(tc.sid), nil) + c := echo.New().NewContext(req, httptest.NewRecorder()) + he := requireHTTPError(t, s.HandleGetVideoPlaylist(c)) + require.Equal(t, http.StatusBadRequest, he.Code) + }) + } +} + // Make sure all .m3u8 outputs use LF line endings (no CRLF), match // the HLS spec recommendation. func TestPlaylistLineEndings(t *testing.T) { - pl := masterPlaylist(fixtureMetafile(), fixtureURI, nil, nil) + pl := masterPlaylist(fixtureMetafile(), fixtureURI, fixtureSID, nil, nil) require.False(t, strings.Contains(pl, "\r"), "playlist should not contain carriage returns") } diff --git a/pkg/spxrpc/stubs.go b/pkg/spxrpc/stubs.go index 730f98f0e..d8ea83cc4 100644 --- a/pkg/spxrpc/stubs.go +++ b/pkg/spxrpc/stubs.go @@ -917,10 +917,11 @@ func (s *Server) HandlePlaceStreamPlaybackGetVideoBlob(c echo.Context) error { defer span.End() cid := c.QueryParam("cid") did := c.QueryParam("did") + sid := c.QueryParam("sid") var out io.Reader var handleErr error - // func (s *Server) handlePlaceStreamPlaybackGetVideoBlob(ctx context.Context,cid string,did string) (io.Reader, error) - out, handleErr = s.handlePlaceStreamPlaybackGetVideoBlob(ctx, cid, did) + // func (s *Server) handlePlaceStreamPlaybackGetVideoBlob(ctx context.Context,cid string,did string,sid string) (io.Reader, error) + out, handleErr = s.handlePlaceStreamPlaybackGetVideoBlob(ctx, cid, did, sid) if handleErr != nil { return handleErr } @@ -939,6 +940,7 @@ func (s *Server) HandlePlaceStreamPlaybackGetVideoPlaylist(c echo.Context) error } end = &end_val } + sid := c.QueryParam("sid") var start *int if p := c.QueryParam("start"); p != "" { @@ -952,8 +954,8 @@ func (s *Server) HandlePlaceStreamPlaybackGetVideoPlaylist(c echo.Context) error uri := c.QueryParam("uri") var out io.Reader var handleErr error - // func (s *Server) handlePlaceStreamPlaybackGetVideoPlaylist(ctx context.Context,end *int,start *int,track string,uri string) (io.Reader, error) - out, handleErr = s.handlePlaceStreamPlaybackGetVideoPlaylist(ctx, end, start, track, uri) + // func (s *Server) handlePlaceStreamPlaybackGetVideoPlaylist(ctx context.Context,end *int,sid string,start *int,track string,uri string) (io.Reader, error) + out, handleErr = s.handlePlaceStreamPlaybackGetVideoPlaylist(ctx, end, sid, start, track, uri) if handleErr != nil { return handleErr } diff --git a/pkg/streamplace/playbackgetVideoBlob.go b/pkg/streamplace/playbackgetVideoBlob.go index 9ce138b57..43b0cb410 100644 --- a/pkg/streamplace/playbackgetVideoBlob.go +++ b/pkg/streamplace/playbackgetVideoBlob.go @@ -15,12 +15,16 @@ import ( // // cid: BLAKE-3 BDASL CID of the requested blob. // did: DID of the account that owns the record this blob is being served for. Used for egress/usage accounting, not for access control — the blob is served purely by CID. -func PlaybackGetVideoBlob(ctx context.Context, c lexutil.LexClient, cid string, did string) ([]byte, error) { +// sid: Opaque playback session identifier, propagated unmodified from the media playlist that referenced this blob. Logged for view-count correlation; not used for access control. +func PlaybackGetVideoBlob(ctx context.Context, c lexutil.LexClient, cid string, did string, sid string) ([]byte, error) { buf := new(bytes.Buffer) params := map[string]interface{}{} params["cid"] = cid params["did"] = did + if sid != "" { + params["sid"] = sid + } if err := c.LexDo(ctx, lexutil.Query, "", "place.stream.playback.getVideoBlob", params, nil, buf); err != nil { return nil, err } diff --git a/pkg/streamplace/playbackgetVideoPlaylist.go b/pkg/streamplace/playbackgetVideoPlaylist.go index e3c8c9b1d..10d4d7241 100644 --- a/pkg/streamplace/playbackgetVideoPlaylist.go +++ b/pkg/streamplace/playbackgetVideoPlaylist.go @@ -14,16 +14,20 @@ import ( // PlaybackGetVideoPlaylist calls the XRPC method "place.stream.playback.getVideoPlaylist". // // end: End time in nanoseconds. Omit to include all remaining content. +// sid: Opaque playback session identifier. Omit on the master playlist request; the server generates one and embeds it in every sub-playlist URL it returns. Players never have to construct it themselves — they just follow the URLs the master playlist hands them, which carry the sid into media-playlist + segment requests. Used downstream to correlate a player's playlist + segment fetches for view-count accounting. // start: Start time in nanoseconds from the beginning of the video. Defaults to 0. // track: Track ID (stringified u32 matching the MUXL container) for a single-track media playlist. Omit for the master playlist. // uri: AT-URI of the record to play back (e.g. at://did:plc:.../place.stream.video/). -func PlaybackGetVideoPlaylist(ctx context.Context, c lexutil.LexClient, end int64, start int64, track string, uri string) ([]byte, error) { +func PlaybackGetVideoPlaylist(ctx context.Context, c lexutil.LexClient, end int64, sid string, start int64, track string, uri string) ([]byte, error) { buf := new(bytes.Buffer) params := map[string]interface{}{} if end != 0 { params["end"] = end } + if sid != "" { + params["sid"] = sid + } if start != 0 { params["start"] = start } -- knotmirror 0.0.1